ng2-pdfjs-viewer
Version:
The most comprehensive Angular PDF viewer, powered by Mozilla PDF.js 6 — view, annotate, sign, fill forms, search, and read aloud from one component. 8.3M+ downloads, mobile-first, production-ready.
3,179 lines • 145 kB
JavaScript
import * as i0 from '@angular/core';
import { EventEmitter, isDevMode, Input, Output, ViewChild, Component, NgModule } from '@angular/core';
import * as i1 from '@angular/common';
import { CommonModule } from '@angular/common';
import { PdfAiAssistant } from 'ng2-pdfjs-viewer/ai';
export { PdfAiAssistant } from 'ng2-pdfjs-viewer/ai';
// Single action queue with readiness-gated execution
class ActionQueueManager {
// Bounded history for getActionStatus()/getQueueStatus(); prevents unbounded
// growth in long-lived viewers (every dispatched action has a unique id).
static MAX_EXECUTED_RESULTS = 100;
actionQueue = [];
executedActions = new Map();
isDocumentLoaded = false;
postMessageReadiness = 0;
diagnosticLogs = false;
postMessageExecutor;
constructor(diagnosticLogs = false) {
this.diagnosticLogs = diagnosticLogs;
}
setDiagnosticLogs(enabled) {
this.diagnosticLogs = enabled;
}
updateReadiness(readiness) {
this.postMessageReadiness = readiness;
}
queueAction(action, readinessLevel) {
this.actionQueue.push({ action, readinessLevel });
}
onDocumentLoaded() {
this.isDocumentLoaded = true;
this.processQueuedActions();
}
// Execute every queued action whose readiness requirement is now met
processQueuedActions() {
const ready = [];
this.actionQueue = this.actionQueue.filter((item) => {
const canExecute = this.postMessageReadiness >= item.readinessLevel &&
(item.readinessLevel < 5 || this.isDocumentLoaded);
if (canExecute) {
ready.push(item.action);
}
return !canExecute;
});
ready.forEach((action) => this.executeAction(action));
}
async executeAction(action) {
const result = {
actionId: action.id,
success: false,
timestamp: Date.now(),
};
try {
const response = await this.executeActionViaPostMessage(action);
result.success = true;
// Query actions return a payload in the wrapper's control response
if (response && typeof response === "object" && "data" in response) {
result.data = response.data;
}
action.resolver?.(result);
}
catch (error) {
result.error = error instanceof Error ? error.message : String(error);
if (this.diagnosticLogs) {
console.error(`ActionQueueManager: Error executing action ${action.action}:`, error);
}
// A requeued action retries later with the same id and resolver - don't
// settle the caller's promise on the transient failure.
if (action.requeued) {
action.requeued = false;
}
else {
action.resolver?.(result);
}
}
this.recordResult(action.id, result);
return result;
}
recordResult(id, result) {
if (this.executedActions.size >= ActionQueueManager.MAX_EXECUTED_RESULTS &&
!this.executedActions.has(id)) {
const oldest = this.executedActions.keys().next().value;
if (oldest !== undefined) {
this.executedActions.delete(oldest);
}
}
this.executedActions.set(id, result);
}
async executeActionViaPostMessage(action) {
if (!this.postMessageExecutor) {
throw new Error("PostMessage executor not set");
}
return await this.postMessageExecutor(action);
}
setPostMessageExecutor(executor) {
this.postMessageExecutor = executor;
}
getActionStatus(actionId) {
const result = this.executedActions.get(actionId);
if (!result) {
const inQueue = this.actionQueue.some((item) => item.action.id === actionId);
return inQueue ? "pending" : "not-found";
}
return result.success ? "completed" : "failed";
}
// Settle document-gated (level-5) actions when a document load fails -
// they can never execute against the failed document, and without this
// consumer awaits (setAnnotations, search, getDocumentText, ...) hang
// forever. Lower-level actions stay queued: the viewer itself is alive.
failDocumentActions(reason) {
this.actionQueue = this.actionQueue.filter((item) => {
if (item.readinessLevel < 5) {
return true;
}
item.action.resolver?.({
actionId: item.action.id,
success: false,
error: reason,
timestamp: Date.now(),
});
return false;
});
}
// Drop queued actions (settling their callers' promises) and clear history
clearQueues() {
for (const item of this.actionQueue) {
item.action.resolver?.({
actionId: item.action.id,
success: false,
error: "Action discarded: queue cleared",
timestamp: Date.now(),
});
}
this.actionQueue = [];
this.executedActions.clear();
}
// Full reset for a new document load (pdfSrc change / refresh)
reset() {
this.clearQueues();
this.isDocumentLoaded = false;
this.postMessageReadiness = 0;
}
getQueueStatus() {
return {
queuedActions: this.actionQueue.length,
executedActions: this.executedActions.size,
};
}
}
// Property normalization between component inputs and PDF.js viewer values
// Mode/name lists shared by the to- and from-viewer transforms. PDF.js encodes
// scroll and spread modes as integer enums, so for those the array index is the
// enum value and the order here doubles as the numeric->name map (keep it in
// sync with PDF.js). Declaring each list once keeps the input whitelist and the
// index map from drifting apart, and hoisting them to module scope avoids
// re-allocating the array on every viewer state-sync event.
const ZOOM_NAMES = [
"auto",
"page-fit",
"page-width",
"page-actual",
];
const CURSOR_MODES = ["select", "hand", "zoom"];
const SCROLL_MODES = [
"vertical",
"horizontal",
"wrapped",
"page",
];
const SPREAD_MODES = ["none", "odd", "even"];
const PAGE_MODES = [
"none",
"thumbs",
"bookmarks",
"attachments",
];
// Lowercase + whitelist with fallback
const pick = (value, allowed, fallback) => {
const v = value ? value.toLowerCase() : "";
return allowed.includes(v) ? v : fallback;
};
class PropertyTransformers {
static transformZoom = {
toViewer: (zoom) => {
if (!zoom)
return "auto";
const v = zoom.toLowerCase();
// Named zooms normalize to lowercase; numeric strings pass through
return ZOOM_NAMES.includes(v) ? v : zoom;
},
fromViewer: (viewerZoom) => {
if (typeof viewerZoom === "string")
return viewerZoom;
// Numeric scale as a plain string ("1.25") - PDF.js accepts it directly
if (typeof viewerZoom === "number")
return viewerZoom.toString();
return "auto";
},
};
static transformRotation = {
toViewer: (rotation) => ((rotation % 360) + 360) % 360,
fromViewer: (viewerRotation) => typeof viewerRotation === "number" ? viewerRotation : 0,
};
static transformCursor = {
toViewer: (cursor) => pick(cursor, CURSOR_MODES, "select"),
fromViewer: (viewerCursor) => typeof viewerCursor === "string" ? viewerCursor : "select",
};
static transformScroll = {
toViewer: (scroll) => pick(scroll, SCROLL_MODES, "vertical"),
fromViewer: (viewerScroll) => {
if (typeof viewerScroll === "number") {
return SCROLL_MODES[viewerScroll] || "vertical";
}
return typeof viewerScroll === "string" ? viewerScroll : "vertical";
},
};
static transformSpread = {
toViewer: (spread) => pick(spread, SPREAD_MODES, "none"),
fromViewer: (viewerSpread) => {
if (typeof viewerSpread === "number") {
return SPREAD_MODES[viewerSpread] || "none";
}
return typeof viewerSpread === "string" ? viewerSpread : "none";
},
};
static transformPageMode = {
toViewer: (pageMode) => pick(pageMode, PAGE_MODES, "none"),
fromViewer: (viewerPageMode) => typeof viewerPageMode === "string" ? viewerPageMode : "none",
};
}
// The iframe sandbox shipped on every viewer embed. allow-popups (+ escape)
// lets external PDF links open in a new, unsandboxed tab - the only link
// behavior that works without granting the document navigation rights over
// the host page.
//
// Honest scope: because the viewer is same-origin and needs allow-scripts +
// allow-same-origin, this sandbox is NOT a containment boundary against a
// compromised PDF.js (same-origin content can reach the parent document).
// It hardens link/navigation behavior of COOPERATIVE viewer code; defenses
// against hostile documents are PDF.js's own parsing/rendering isolation.
const BASE_IFRAME_SANDBOX = "allow-forms allow-scripts allow-same-origin allow-modals allow-downloads " +
"allow-popups allow-popups-to-escape-sandbox";
// Tokens consumers may add via [iframeSandbox]. Deliberately excludes anything
// that would let viewer content reach outside a user-initiated navigation.
// Prefer 'allow-top-navigation-by-user-activation' over 'allow-top-navigation':
// the latter lets a hostile document redirect the whole host page without any
// user gesture (frame-phishing) - only use it with fully trusted documents.
const ALLOWED_EXTRA_SANDBOX_TOKENS = new Set([
"allow-top-navigation",
"allow-top-navigation-by-user-activation",
"allow-presentation",
]);
const PROPERTY_REGISTRY = [
// Control visibility (DOM toggles)
{ prop: "showOpenFile", action: "show-openfile", level: 3, init: "always" },
{ prop: "showDownload", action: "show-download", level: 3, init: "always" },
{ prop: "showPrint", action: "show-print", level: 3, init: "always" },
{ prop: "showFullScreen", action: "show-fullscreen", level: 3, init: "always" },
{ prop: "showFind", action: "show-find", level: 3, init: "always" },
{ prop: "showViewBookmark", action: "show-bookmark", level: 3, init: "always" },
{ prop: "showAnnotations", action: "show-annotations", level: 3, init: "always" },
// Toolbar/sidebar group visibility
{ prop: "showToolbarLeft", action: "show-toolbar-left", level: 3, init: "always" },
{ prop: "showToolbarMiddle", action: "show-toolbar-middle", level: 3, init: "always" },
{ prop: "showToolbarRight", action: "show-toolbar-right", level: 3, init: "always" },
{ prop: "showSecondaryToolbarToggle", action: "show-secondary-toolbar-toggle", level: 3, init: "always" },
// chromeless forces both hidden while leaving the consumer's own
// showToolbar/showSidebar bindings untouched (see the chromeless @Input).
{ prop: "showToolbar", action: "show-toolbar", level: 3, init: "always", get: (c) => c.showToolbar && !c.chromeless },
{ prop: "showSidebar", action: "show-sidebar", level: 3, init: "always", get: (c) => c.showSidebar && !c.chromeless },
{ prop: "showSidebarLeft", action: "show-sidebar-left", level: 3, init: "always" },
{ prop: "showSidebarRight", action: "show-sidebar-right", level: 3, init: "always" },
// Layout & responsive customization
{ prop: "toolbarDensity", action: "set-toolbar-density", level: 4, init: "always" },
{ prop: "sidebarWidth", action: "set-sidebar-width", level: 4, init: "truthy" },
{ prop: "toolbarPosition", action: "set-toolbar-position", level: 4, init: "always" },
{ prop: "sidebarPosition", action: "set-sidebar-position", level: 4, init: "always" },
{ prop: "responsiveBreakpoint", action: "set-responsive-breakpoint", level: 4, init: "defined" },
// Modes & navigation
{ prop: "cursor", action: "set-cursor", level: 4, init: "truthy" },
{ prop: "scroll", action: "set-scroll", level: 4, init: "truthy" },
{ prop: "spread", action: "set-spread", level: 4, init: "truthy" },
{ prop: "zoom", action: "set-zoom", level: 4, init: "truthy" },
{ prop: "pageMode", action: "update-page-mode", level: 4, init: "truthy" },
{ prop: "page", action: "set-page", level: 5, init: "truthy", get: (c) => c._page },
{ prop: "rotation", action: "set-rotation", level: 5, init: false },
{ prop: "namedDest", action: "go-to-named-dest", level: 5, init: "nonempty" },
{ prop: "rotateCW", action: "trigger-rotate-cw", level: 5, init: "true" },
{ prop: "rotateCCW", action: "trigger-rotate-ccw", level: 5, init: "true" },
// Theme & visual customization (DOM-only, applies as soon as viewer loads)
{ prop: "theme", action: "set-theme", level: 1, init: "always", payload: (v) => v || "auto" },
{ prop: "primaryColor", action: "set-primary-color", level: 1, init: "truthy" },
{ prop: "backgroundColor", action: "set-background-color", level: 1, init: "truthy" },
{ prop: "pageBorderColor", action: "set-page-border-color", level: 1, init: "truthy" },
{ prop: "pageSpacing", action: "set-page-spacing", level: 3, init: "truthy" },
{ prop: "toolbarColor", action: "set-toolbar-color", level: 1, init: "truthy" },
{ prop: "textColor", action: "set-text-color", level: 1, init: "truthy" },
{ prop: "borderRadius", action: "set-border-radius", level: 1, init: "truthy" },
{
prop: "customCSS",
action: "set-custom-css",
level: 1,
init: "truthy",
payload: (v, c) => (c.cspNonce ? { css: v, nonce: c.cspNonce } : v),
},
// Misc configuration. downloadFileName is level 5 because PDF.js overwrites
// its _contentDispositionFilename during document load.
{ prop: "useOnlyCssZoom", action: "set-css-zoom", level: 3, init: "defined" },
// 'always': the embedded PDF.js default ('top') is sandbox-blocked, so the
// component's 'blank' default must reach the viewer on every load.
{ prop: "externalLinkTarget", action: "set-external-link-target", level: 3, init: "always" },
{ prop: "rememberLastView", action: "set-remember-last-view", level: 3, init: "defined" },
{ prop: "downloadFileName", action: "set-download-filename", level: 5, init: "truthy" },
{ prop: "urlValidation", action: "set-url-validation", level: 3, init: false },
{ prop: "diagnosticLogs", action: "set-diagnostic-logs", level: 3, init: false },
{ prop: "highlightEditorColors", action: "set-highlight-editor-colors", level: 3, init: "nonempty" },
// The hook object stays host-side; the wrapper only needs the on/off bit
{ prop: "signatureStorage", action: "set-signature-storage", level: 3, init: "truthy", payload: (v) => !!v },
// Setter-dispatched at runtime; registry entry re-applies the active editor
// after iframe reloads (pdfSrc change / refresh). 'none' is the PDF.js
// default and never needs sending.
{
prop: "annotationEditor",
action: "set-annotation-editor-mode",
level: 5,
init: "truthy",
get: (c) => c._annotationEditor === "none"
? undefined
: c._annotationEditor,
},
];
const REGISTRY_BY_PROP = {};
const ACTION_READINESS = {
// On-demand triggers without an owning property
"trigger-download": 5,
"trigger-print": 5,
"go-to-last-page": 5,
// Annotation editing + document queries need a loaded document
"set-annotation-editor-mode": 5,
"get-annotations": 5,
"set-annotations": 5,
"save-document": 5,
"search": 5,
"search-next": 5,
"search-previous": 5,
"clear-search": 5,
// Forms need field objects from the loaded document
"get-form-data": 5,
"set-form-data": 5,
"set-form-field": 5,
// Content protection is DOM-level
"set-content-protection": 3,
"set-watermark": 3,
// Text extraction + read-aloud need the document
"get-document-text": 5,
"read-aloud": 5,
};
for (const entry of PROPERTY_REGISTRY) {
REGISTRY_BY_PROP[entry.prop] = entry;
ACTION_READINESS[entry.action] = entry.level;
}
// Wrapper-side event notifications enabled at init. All @Output emitters exist
// unconditionally, so these are enabled unconditionally; 'enable-idle' is the
// exception (it installs document-wide activity listeners in the iframe) and
// is only sent when the consumer actually subscribed to (onIdle).
const ENABLE_EVENT_ACTIONS = [
"enable-before-print",
"enable-after-print",
"enable-pages-loaded",
"enable-page-change",
"enable-document-error",
"enable-document-init",
"enable-pages-init",
"enable-presentation-mode-changed",
"enable-open-file",
"enable-find",
"enable-update-find-matches-count",
"enable-metadata-loaded",
"enable-outline-loaded",
"enable-page-rendered",
"enable-annotation-layer-rendered",
"enable-bookmark-click",
"enable-sidebar-view-changed",
"enable-layers-changed",
"enable-named-action",
"enable-document-properties",
];
// Properties whose setters dispatch on their own - skipped in ngOnChanges to
// avoid a second postMessage per change
const SETTER_DISPATCHED_PROPS = new Set([
"zoom",
"rotation",
"cursor",
"scroll",
"spread",
"pageMode",
"page",
"diagnosticLogs",
"annotationEditor",
"formData",
"contentProtection",
]);
// Auto-actions are read at the next document load; changing them mid-session
// dispatches nothing
const DOCUMENT_LOAD_PROPS = new Set([
"downloadOnLoad",
"printOnLoad",
"showLastPageOnLoad",
]);
// Config-object inputs fan out to the individual properties their setters
// populate, so post-init changes propagate to the viewer (they used to be
// silently dropped after init)
const CONFIG_FANOUT = {
controlVisibility: [
"showDownload", "showPrint", "showFind", "showFullScreen",
"showOpenFile", "showViewBookmark", "showAnnotations",
],
groupVisibility: [
"showToolbarLeft", "showToolbarMiddle", "showToolbarRight",
"showSecondaryToolbarToggle", "showSidebar", "showSidebarLeft", "showSidebarRight",
],
layoutConfig: [
"toolbarDensity", "sidebarWidth", "toolbarPosition",
"sidebarPosition", "responsiveBreakpoint",
],
themeConfig: [
"theme", "primaryColor", "backgroundColor", "pageBorderColor",
"pageSpacing", "toolbarColor", "textColor", "borderRadius", "customCSS",
],
viewerConfig: ["useOnlyCssZoom", "externalLinkTarget", "rememberLastView"],
autoActions: ["rotateCW", "rotateCCW"],
errorHandling: [],
// chromeless is a preset, not a config object, but it reuses the fanout so a
// runtime toggle re-dispatches the (get-overridden) toolbar/sidebar actions.
chromeless: ["showToolbar", "showSidebar"],
};
function hasObservers(emitter) {
return (emitter.observed === true ||
(emitter.observers?.length ?? 0) > 0);
}
// Config-object inputs are commonly bound to getters that return a FRESH
// object every change-detection cycle. Reference identity then flags a
// "change" each cycle - only the content matters.
// (Exported for unit tests; not part of the public package API.)
function shallowEquals(a, b) {
if (a === b)
return true;
if (!a || !b || typeof a !== "object" || typeof b !== "object")
return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
return (aKeys.length === bKeys.length && aKeys.every((k) => a[k] === b[k]));
}
// #endregion
class PdfJsViewerComponent {
// #region Component Properties
iframe;
static lastID = 0;
viewerId = `ng2-pdfjs-viewer-ID${++PdfJsViewerComponent.lastID}`;
// #region Event Outputs
onBeforePrint = new EventEmitter();
onAfterPrint = new EventEmitter();
onDocumentLoad = new EventEmitter();
onPageChange = new EventEmitter();
onScaleChange = new EventEmitter();
onRotationChange = new EventEmitter();
// New high-value events for enhanced PDF viewer functionality
onDocumentError = new EventEmitter();
onDocumentInit = new EventEmitter();
onPagesInit = new EventEmitter();
onPresentationModeChanged = new EventEmitter();
onOpenFile = new EventEmitter();
onFind = new EventEmitter();
onUpdateFindMatchesCount = new EventEmitter();
onMetadataLoaded = new EventEmitter();
onOutlineLoaded = new EventEmitter();
onPageRendered = new EventEmitter();
// New high-value events
onAnnotationLayerRendered = new EventEmitter();
onBookmarkClick = new EventEmitter();
onIdle = new EventEmitter();
// Fired when PDF.js shows its password dialog for a protected document.
// The loading spinner is dropped automatically so the dialog is usable.
onPasswordPrompt = new EventEmitter();
// Annotation editor undo/redo/empty state - drives "unsaved changes" UX
onAnnotationEditorStateChange = new EventEmitter();
// Page organization events (reorder/delete/extract/merge in the sidebar)
onPagesEdited = new EventEmitter();
// Read-aloud progress: reading | paused | stopped | finished | error
onReadAloudStateChange = new EventEmitter();
// Sidebar panel switches (thumbnails/outline/attachments/layers)
onSidebarViewChanged = new EventEmitter();
// Optional-content layers: loaded for the document / visibility toggled
onLayersChanged = new EventEmitter();
// Named actions triggered from inside the document (GoToPage, Print, ...)
onNamedAction = new EventEmitter();
// User opened the document-properties dialog
onDocumentProperties = new EventEmitter();
// #endregion
// #region Basic Configuration Properties
viewerFolder;
externalWindow = false;
target = "_blank";
showSpinner = true;
downloadFileName;
locale;
useOnlyCssZoom = false;
// Where external PDF links open. The embedded PDF.js default ('top') is
// blocked by the iframe sandbox, leaving links dead (issue #304); 'blank'
// works with the sandbox's allow-popups and is the safe default.
externalLinkTarget = "blank";
// Restore the previous reading position (page/zoom/sidebar) on reload.
// Set false to always open documents at page 1 / initial view (issue #299).
rememberLastView = true;
// Active annotation editor tool. Two-way bindable: user toolbar clicks emit
// annotationEditorChange. Editing requires a loaded document (level 5).
set annotationEditor(mode) {
if (mode === this._annotationEditor) {
return;
}
this._annotationEditor = mode ?? "none";
this.dispatchAction("set-annotation-editor-mode", this._annotationEditor, "property-change");
}
get annotationEditor() {
return this._annotationEditor;
}
_annotationEditor = "none";
annotationEditorChange = new EventEmitter();
// Highlight palette for the highlight editor, PDF.js format:
// 'yellow=#FFFF98,green=#53FFBC,...'. Applied before the document opens.
highlightEditorColors;
// Opt-in PDF.js signature editor (draw / type / upload image). Saved as
// stamp-style annotations - an eSign convenience, NOT cryptographic signing.
// Init-time option: changing it after load requires a reload.
enableSignatureEditor = false;
// Host-side persistence for the signature editor's saved signatures.
// When set, the viewer's "save signature" feature round-trips through these
// callbacks (e.g. to a server, per user) instead of the iframe's
// localStorage. Use with [enableSignatureEditor].
signatureStorage;
// Re-render page CONTENT with custom colors (true dark mode for pages, not
// just viewer chrome). Example: { background: '#1e1e1e', foreground: '#e8e8e8' }.
// Init-time option: changing it after load requires a reload.
pageColors;
// Raw allowlisted PDF.js AppOptions passthrough for init-time options
// (e.g. { printResolution: 300, sidebarViewOnLoad: 1, enableComment: true }).
// Keys outside the wrapper's allowlist are ignored with a console warning.
// Init-time: changing it after load requires a reload.
pdfJsOptions;
// Opt-in PDF.js comment editor: threaded comment popups on highlights with
// edit/delete and undo/redo. Init-time option: changing requires a reload.
enableCommentEditor = false;
// Opt-in in-viewer page organization: drag-drop reorder, delete, cut/copy/
// paste, extract and merge pages from the sidebar's views manager.
// Init-time option: changing requires a reload.
enablePageEditing = false;
// Show/hide the entire viewer toolbar (pair with customToolbarTpl to ship a
// fully custom host-side toolbar)
showToolbar = true;
// Chromeless / embedded mode: hide the toolbar and sidebar in one switch so
// the iframe shows just the scrolling pages. Shorthand for showToolbar=false
// + showSidebar=false; it overrides them without mutating those bindings, so
// flipping it back restores whatever they were. There is still an iframe and
// its own scroll container - use pageOverlayTpl if you need per-page host DOM.
chromeless = false;
// Host-side replacement toolbar rendered ABOVE the viewer iframe. Template
// context: let-viewer (the component instance) for driving the public API,
// e.g. <ng-template #tb let-viewer><button (click)="viewer.setPage(1)">...
customToolbarTpl;
// Host-side sidebar panel rendered BESIDE the viewer iframe (left). Same
// template context as customToolbarTpl: let-viewer (the component
// instance). Pair with [groupVisibility]="{ sidebar: false }" to replace
// the built-in sidebar entirely. Size it with your own CSS width.
customSidebarTpl;
// Built-in chat-with-the-document panel (floating, bottom-right). The
// library only calls the endpoint configured here - never any AI service
// of its own. Answers cite pages as [p.3]; citations are clickable and
// jump the viewer to that page. For fully custom UI use PdfAiAssistant +
// getDocumentText() instead.
aiAssistantConfig;
// AI panel state (template-bound)
aiPanelOpen = false;
aiBusy = false;
aiMessages = [];
aiClient;
aiClientConfig;
aiDocText;
// Bumped whenever the document context changes; in-flight aiAsk
// continuations compare against it and abandon stale answers
aiGeneration = 0;
aiAbort;
// Drop AI panel state tied to the current document and abandon any
// in-flight request, so a slow answer about the OLD document can't land
// in (or re-enable) the new document's chat.
invalidateAiState(clearChat) {
this.aiGeneration++;
this.aiAbort?.abort();
this.aiAbort = undefined;
this.aiDocText = undefined;
if (clearChat) {
this.aiMessages = [];
}
this.aiBusy = false;
}
// Angular template rendered as an overlay on every page (watermark badges,
// stamps, review UI). Context: let-page (1-based page number). The overlay
// wrapper is pointer-events:none; re-enable on your own elements as needed.
// Setter so clearing/replacing the template also unmounts existing overlays.
_pageOverlayTpl;
set pageOverlayTpl(value) {
if (value === this._pageOverlayTpl) {
return;
}
this._pageOverlayTpl = value;
this.destroyPageOverlays();
if (value) {
this.mountOverlaysOnRenderedPages();
}
}
get pageOverlayTpl() {
return this._pageOverlayTpl;
}
// Request headers sent when the component fetches a string pdfSrc URL
// (JWT bearer tokens, API keys). When set, the component downloads the
// document itself and hands the viewer a local blob - the URL never needs
// to be reachable by the viewer iframe directly.
httpHeaders;
// Send cookies/credentials with the component-side fetch of pdfSrc.
withCredentials = false;
// Download progress while the component fetches pdfSrc (only emitted for
// the httpHeaders/withCredentials fetch path). total is 0 when the server
// sends no content-length.
onProgress = new EventEmitter();
// Monotonic token so a pdfSrc change mid-fetch abandons the stale download
authLoadToken = 0;
// AcroForm field values, two-way bindable: [(formData)]. Setting writes the
// fields into the viewer; user edits in the viewer emit formDataChange.
set formData(value) {
this._formData = value ?? {};
this.dispatchAction("set-form-data", this._formData, "property-change");
}
get formData() {
return this._formData;
}
_formData = {};
formDataChange = new EventEmitter();
// Client-side content protection (deterrence, not DRM): block print/save
// shortcuts, disable text selection, render a per-page watermark.
set contentProtection(config) {
this._contentProtection = config ?? {};
this.dispatchAction("set-content-protection", {
blockPrint: this._contentProtection.blockPrint === true,
blockDownload: this._contentProtection.blockDownload === true,
disableTextSelection: this._contentProtection.disableTextSelection === true,
}, "property-change");
if (this._contentProtection.blockPrint !== undefined) {
this.dispatchAction("show-print", !this._contentProtection.blockPrint, "property-change");
}
if (this._contentProtection.blockDownload !== undefined) {
this.dispatchAction("show-download", !this._contentProtection.blockDownload, "property-change");
}
this.dispatchAction("set-watermark", this._contentProtection.watermark ?? null, "property-change");
}
get contentProtection() {
return this._contentProtection;
}
_contentProtection = {};
// Additional iframe sandbox permissions, validated against a fixed
// allowlist; anything else is ignored (issue #304 asked for
// allow-top-navigation for trusted documents).
set iframeSandbox(value) {
const requested = (value || "").split(/\s+/).filter(Boolean);
const accepted = requested.filter((token) => ALLOWED_EXTRA_SANDBOX_TOKENS.has(token));
const rejected = requested.filter((token) => !ALLOWED_EXTRA_SANDBOX_TOKENS.has(token));
if (rejected.length > 0) {
console.warn(`ng2-pdfjs-viewer: ignoring sandbox tokens not in the allowlist: ${rejected.join(", ")}`);
}
this._extraSandboxTokens = accepted;
}
get iframeSandbox() {
return this._extraSandboxTokens.join(" ");
}
_extraSandboxTokens = [];
get effectiveSandbox() {
return this._extraSandboxTokens.length === 0
? BASE_IFRAME_SANDBOX
: `${BASE_IFRAME_SANDBOX} ${this._extraSandboxTokens.join(" ")}`;
}
set diagnosticLogs(value) {
this._diagnosticLogs = value;
// Update action queue manager
this.actionQueueManager.setDiagnosticLogs(value);
// Send to wrapper
this.dispatchAction("set-diagnostic-logs", value, "property-change");
}
get diagnosticLogs() {
return this._diagnosticLogs;
}
_diagnosticLogs = false;
// #endregion
// #region Control Visibility Properties
showOpenFile = true;
// Default true since PDF.js 5.7: the editor toolbar (highlight/text/draw/
// stamp, plus the opt-in signature/comment editors) is core viewer UI.
// Set false to hide the editing buttons entirely.
showAnnotations = true;
showDownload = true;
showViewBookmark = true;
showPrint = true;
showFullScreen = true;
showFind = true;
// #endregion
// #region Auto-Action Properties
downloadOnLoad = false;
printOnLoad = false;
rotateCW = false;
rotateCCW = false;
showLastPageOnLoad = false;
// #endregion
// #region Navigation Properties
namedDest;
// #endregion
// #region Error Handling Properties
errorOverride = false;
errorAppend = true;
errorMessage;
urlValidation = true;
customSecurityTpl;
// Security warning state
securityWarning = null;
// #endregion
// #region Theme & Visual Customization Properties
theme = "auto";
primaryColor;
backgroundColor;
pageBorderColor;
pageSpacing;
toolbarColor;
textColor;
borderRadius;
customCSS;
cspNonce;
iframeTitle; // CSP nonce for customCSS (optional)
// #endregion
// #region Loading & Spinner Customization
customSpinnerTpl;
spinnerClass;
// #endregion
// #region Error Display Customization
customErrorTpl;
errorClass;
// #endregion
// #region Toolbar/Sidebar Group Visibility
showToolbarLeft = true;
showToolbarMiddle = true;
showToolbarRight = true;
showSecondaryToolbarToggle = true;
showSidebar = true;
showSidebarLeft = true;
showSidebarRight = true;
// #endregion
// #region Layout & Responsive Customization
toolbarDensity = "default";
sidebarWidth; // e.g., '280px'
toolbarPosition = "top";
sidebarPosition = "left";
responsiveBreakpoint;
// #endregion
// Internal loading state for overlay control
isLoading = true;
// Internal error state for error display
hasError = false;
currentErrorMessage = "";
errorTemplateData = {};
// Kept for API compatibility; the template binds errorTemplateData directly
// so change detection sees a stable object identity.
getErrorTemplateData() {
return this.errorTemplateData;
}
updateErrorTemplateData() {
this.errorTemplateData = {
errorMessage: this.currentErrorMessage,
errorClass: this.errorClass,
};
}
// Helper method to get iframe CSS classes (no per-CD-cycle allocation)
getIframeClasses() {
return this.iframeBorder && this.iframeBorder !== "0" && this.iframeBorder !== 0
? "ng2-pdfjs-viewer-iframe has-border"
: "ng2-pdfjs-viewer-iframe";
}
// Error template button actions
reloadViewer() {
this.refresh();
}
goBack() {
if (window.history.length > 1) {
window.history.back();
}
else {
window.close();
}
}
closeViewer() {
window.close();
}
// #region Convenience Configuration Setters
set controlVisibility(config) {
if (config.download !== undefined)
this.showDownload = config.download;
if (config.print !== undefined)
this.showPrint = config.print;
if (config.find !== undefined)
this.showFind = config.find;
if (config.fullScreen !== undefined)
this.showFullScreen = config.fullScreen;
if (config.openFile !== undefined)
this.showOpenFile = config.openFile;
if (config.viewBookmark !== undefined)
this.showViewBookmark = config.viewBookmark;
if (config.annotations !== undefined)
this.showAnnotations = config.annotations;
}
set autoActions(config) {
if (config.downloadOnLoad !== undefined)
this.downloadOnLoad = config.downloadOnLoad;
if (config.printOnLoad !== undefined)
this.printOnLoad = config.printOnLoad;
if (config.showLastPageOnLoad !== undefined)
this.showLastPageOnLoad = config.showLastPageOnLoad;
if (config.rotateCW !== undefined)
this.rotateCW = config.rotateCW;
if (config.rotateCCW !== undefined)
this.rotateCCW = config.rotateCCW;
}
set errorHandling(config) {
if (config.override !== undefined)
this.errorOverride = config.override;
if (config.append !== undefined)
this.errorAppend = config.append;
if (config.message !== undefined)
this.errorMessage = config.message;
}
set viewerConfig(config) {
if (config.showSpinner !== undefined)
this.showSpinner = config.showSpinner;
if (config.useOnlyCssZoom !== undefined)
this.useOnlyCssZoom = config.useOnlyCssZoom;
if (config.diagnosticLogs !== undefined)
this.diagnosticLogs = config.diagnosticLogs;
if (config.locale !== undefined)
this.locale = config.locale;
if (config.externalLinkTarget !== undefined)
this.externalLinkTarget = config.externalLinkTarget;
if (config.rememberLastView !== undefined)
this.rememberLastView = config.rememberLastView;
}
set themeConfig(config) {
if (config.theme !== undefined)
this.theme = config.theme;
if (config.primaryColor !== undefined)
this.primaryColor = config.primaryColor;
if (config.backgroundColor !== undefined)
this.backgroundColor = config.backgroundColor;
if (config.pageBorderColor !== undefined)
this.pageBorderColor = config.pageBorderColor;
if (config.pageSpacing !== undefined)
this.pageSpacing = config.pageSpacing;
if (config.toolbarColor !== undefined)
this.toolbarColor = config.toolbarColor;
if (config.textColor !== undefined)
this.textColor = config.textColor;
if (config.borderRadius !== undefined)
this.borderRadius = config.borderRadius;
if (config.customCSS !== undefined)
this.customCSS = config.customCSS;
if (config.cspNonce !== undefined)
this.cspNonce = config.cspNonce;
}
set groupVisibility(config) {
if (config.toolbarLeft !== undefined)
this.showToolbarLeft = config.toolbarLeft;
if (config.toolbarMiddle !== undefined)
this.showToolbarMiddle = config.toolbarMiddle;
if (config.toolbarRight !== undefined)
this.showToolbarRight = config.toolbarRight;
if (config.secondaryToolbarToggle !== undefined)
this.showSecondaryToolbarToggle = config.secondaryToolbarToggle;
if (config.sidebar !== undefined)
this.showSidebar = config.sidebar;
if (config.sidebarLeft !== undefined)
this.showSidebarLeft = config.sidebarLeft;
if (config.sidebarRight !== undefined)
this.showSidebarRight = config.sidebarRight;
}
set layoutConfig(config) {
if (config.toolbarDensity !== undefined)
this.toolbarDensity = config.toolbarDensity;
if (config.sidebarWidth !== undefined)
this.sidebarWidth = config.sidebarWidth;
if (config.toolbarPosition !== undefined)
this.toolbarPosition = config.toolbarPosition;
if (config.sidebarPosition !== undefined)
this.sidebarPosition = config.sidebarPosition;
if (config.responsiveBreakpoint !== undefined)
this.responsiveBreakpoint = config.responsiveBreakpoint;
}
// #endregion
// #region Helper function for deprecated properties
static warnedDeprecations = new Set();
setDeprecatedProperty(oldName, newProperty, value) {
if (!PdfJsViewerComponent.warnedDeprecations.has(oldName)) {
PdfJsViewerComponent.warnedDeprecations.add(oldName);
console.warn(`ng2-pdfjs-viewer: Property "${oldName}" is deprecated. Use "${newProperty}" instead.`);
}
this[newProperty] = value;
}
// #endregion
// #region Deprecated Properties (Simplified)
/** @deprecated Use `downloadOnLoad` instead. This property will be removed in a future version. */
set startDownload(value) {
this.setDeprecatedProperty("startDownload", "downloadOnLoad", value);
}
/** @deprecated Use `printOnLoad` instead. This property will be removed in a future version. */
set startPrint(value) {
this.setDeprecatedProperty("startPrint", "printOnLoad", value);
}
/** @deprecated Use `showOpenFile` instead. This property will be removed in a future version. */
set openFile(value) {
this.setDeprecatedProperty("openFile", "showOpenFile", value);
}
/** @deprecated Use `showDownload` instead. This property will be removed in a future version. */
set download(value) {
this.setDeprecatedProperty("download", "showDownload", value);
}
/** @deprecated Use `showPrint` instead. This property will be removed in a future version. */
set print(value) {
this.setDeprecatedProperty("print", "showPrint", value);
}
/** @deprecated Use `showFullScreen` instead. This property will be removed in a future version. */
set fullScreen(value) {
this.setDeprecatedProperty("fullScreen", "showFullScreen", value);
}
/** @deprecated Use `showFind` instead. This property will be removed in a future version. */
set find(value) {
this.setDeprecatedProperty("find", "showFind", value);
}
/** @deprecated Use `showViewBookmark` instead. This property will be removed in a future version. */
set viewBookmark(value) {
this.setDeprecatedProperty("viewBookmark", "showViewBookmark", value);
}
/** @deprecated Use `showLastPageOnLoad` instead. This property will be removed in a future version. */
set lastPage(value) {
this.setDeprecatedProperty("lastPage", "showLastPageOnLoad", value);
}
// #endregion
// #region External Window Properties
externalWindowOptions;
viewerTab;
// #endregion
// #region Security Properties
// iframe sandbox is static for security and Angular compliance
// #endregion
// #region iframe Properties
iframeBorder = "0";
// #endregion
// #region Private Properties
_src;
_page;
isPostMessageReady = false;
postMessageReadiness = 0;
initialConfigQueued = false;
actionQueueManager = new ActionQueueManager(this._diagnosticLogs);
cdr;
appRef;
ngZone;
constructor(cdr, appRef, ngZone) {
this.cdr = cdr;
this.appRef = appRef;
this.ngZone = ngZone;
}
messageIdCounter = 0;
// Monotonic suffix keeps action ids unique even within one millisecond
actionIdCounter = 0;
pendingMessages = new Map();
// Changes that arrived before the viewer was ready, coalesced per property
// (last write wins)
pendingChanges = {};
releaseUrl;
webviewerLoadedHandler;
pdfEventHandlers;
// #endregion
// #region Two-Way Binding Properties
// Private backing fields for two-way binding properties
_zoom = "auto";
_rotation = 0;
_cursor = "select";
_scroll = "vertical";
_spread = "none";
_pageMode = "none";
// Two-way binding Output events
zoomChange = new EventEmitter();
cursorChange = new EventEmitter();
scrollChange = new EventEmitter();
spreadChange = new EventEmitter();
pageModeChange = new EventEmitter();
/**
* Two-way binding for zoom level
* Supports: auto, page-fit, page-width, page-actual, percentage values (e.g., "150%")
*/
get zoom() {
return this._zoom;
}
set zoom(value) {
const normalizedValue = PropertyTransformers.transformZoom.toViewer(value);
if (this._zoom !== normalizedValue) {
this._zoom = normalizedValue;
this.dispatchAction("set-zoom", this._zoom, "property-change");
this.zoomChange.emit(this._zoom);
}
}
/**
* One-way binding for document rotation
* Supports: 0, 90, 180, 270 degrees
*/
set rotation(value) {
const normalizedValue = PropertyTransformers.transformRotation.toViewer(value);
if (this._rotation !== normalizedValue) {
this._rotation = normalizedValue;
this.dispatchAction("set-rotation", this._rotation, "property-change");
}
}
get rotation() {
return this._rotation;
}
/**
* Two-way binding for cursor mode
* Supports: select, hand, zoom
*/
get cursor() {
return this._cursor;
}
set cursor(value) {
const normalizedValue = PropertyTransformers.transformCursor.toViewer(value);
if (this._cursor !== normalizedValue) {
this._cursor = normalizedValue;
this.dispatchAction("set-cursor", this._cursor, "property-change");
this.cursorChange.emit(this._cursor);
}
}
/**
* Two-way binding for scroll mode
* Supports: vertical, horizontal, wrapped, page
*/
get scroll() {
return this._scroll;
}
set scroll(value) {
const normalizedValue = PropertyTransformers.transformScroll.toViewer(value);
if (this._scroll !== normalizedValue) {
this._scroll = normalizedValue;
this.dispatchAction("set-scroll", this._scroll, "property-change");
this.scrollChange.emit(this._scroll);
}
}
/**
* Two-way binding for spread mode
* Supports: none, odd, even
*/
get spread() {
return this._spread;
}
set spread(value) {
const normalizedValue = PropertyTransformers.transformSpread.toViewer(value);
if (this._spread !== normalizedValue) {
this._spread = normalizedValue;
this.dispatchAction("set-spread", this._spread, "property-change");
this.spreadChange.emit(this._spread);
}
}
/**
* Two-way binding for page mode (sidebar state)
* Supports: none, thumbs, bookmarks, attachments
*/
get pageMode() {
return this._pageMode;
}
set pageMode(value) {
const normalizedValue = PropertyTransformers.transformPageMode.toViewer(value);
if (this._pageMode !== normalizedValue) {
this._pageMode = normalizedValue;
this.dispatchAction("update-page-mode", this._pageMode, "property-change");
this.pageModeChange.emit(this._pageMode);
}
}
set page(_page) {
this._page = _page;
if (this.PDFViewerApplication && this.PDFViewerApplication.initialized) {
this.PDFViewerApplication.page = this._page;
}
else {
if (this.diagnosticLogs) {
console.warn("Document is not loaded yet!!!. Try to set page# after full load. Ignore this warning if you are not setting page# using '.' notation. (E.g. pdfViewer.page = 5;)");
}
}
}
get page() {
if (this.PDFViewerApplication && this.PDFViewerApplication.initialized) {
return this.PDFViewerApplication.page;
}
else {
if (this.diagnosticLogs) {
console.warn("Document is not loaded yet!!!. Try to retrieve page# after full load.");
}
return this._page || 1;
}
}
set pdfSrc(_src) {
this._src = _src;
}
get pdfSrc() {
return this._src;
}
// #endregion
// #region PDF.js Application Access Properties
get PDFViewerApplicationOptions() {
let pdfViewerOptions = null;
if (this.externalWindow) {
if (this.viewerTab) {
pdfViewerOptions = this.viewerTab.PDFViewerApplicationOptions;
}
}
else {
// Optional-chained: a static `page="5"` attribute runs input setters
// before the static ViewChild is resolved
if (this.iframe?.nativeElement?.contentWindow) {
pdfViewerOptions =
this.iframe.nativeElement.contentWindow.PDFViewerApplicationOptions;
}
}
return pdfViewerOptions;
}
get PDFViewerApplication() {
let pdfViewer = null;
if (this.externalWindow) {
if (this.viewerTab) {
pdfViewer = this.viewerTab.PDFViewerApplication;
}
}
else {
if (this.iframe?.nativeElement?.contentWindow) {
pdfViewer =
this.iframe.nativeElement.contentWindow.PDFViewerApplication;
}
}
if (this.diagnosticLogs)
console.debug("PdfJsViewer: Viewer ->", pdfViewer);
return pdfViewer;
}
// #endregion
// #endregion
// #region Lifecycle Methods
// SSR guard: the viewer is browser-only (iframe + postMessage). During
// server rendering the lifecycle hooks no-op; the real load happens after
// hydration in the browser.
get isBrowser() {
return typeof window !== "undefined" && typeof document !== "undefined";
}
ngOnInit() {
if (!this.isBrowser) {
return;
}
// Connect action queue manager to PostMessage system
// Wrap sendControlMessage to handle iframe unavailability by re-queuing actions
this.actionQueueManager.setPostMessageExecutor((action) => this.sendControlMessageWithRequeue(action));
// Send diagnostic logs setting to wrapper
this.dispatchAction("set-diagnostic-logs", this._diagnosticLogs, "initial-load");
// Send URL validation setting to wrapper
this.dispatchAction("set-url-validation", this.urlValidation, "initial-load");
// Set up PostMessage listener
this.setupMessageListener();
// Note: PDF loading moved to ngAfterViewInit() to ensure iframe is ready
// This prevents "Cannot read properties of null (reading 'location')" error
// when pdfSrc is a Blob (Issue #283)
// Bind events.
this.bindToPdfJsEventBus();
}
ngAfterViewInit() {
if (!this.isBrowser) {
return;
}
// Angular only allows a static sandbox attribute in templates (NG0910), so
// extra allowlisted tokens are applied natively - safe here because the
// iframe has not navigated yet (sandbox applies to subsequent loads).
if (this._extraSandboxTokens.length > 0 && this.iframe?.nativeElement) {
this.iframe.nativeElement.setAttribute("sandbox", this.effectiveSandbox);
}
// Load PDF after view is initialized - trust Angular's lifecycle guarantee
// that iframe.nativeElement.contentWindow is now available
if (!this.externalWindow) {
this.loadPdf();
}
}
ngOnChanges(changes) {
// Handle pdfSrc changes - reload the PDF (Issue #283)
// Trust Angular's change detection event to trigger reload when needed
if (changes['pdfSrc'] && !changes['pdfSrc'].firstChange) {
// pdfSrc changed after initialization - reload the PDF
if (!this.externalWindow) {
// Show spinner immediately when PDF source changes (Issue #275)
this.isLoading = true;
this.hasError = false;
this.currentErrorMessage = "";
// Don't wait for the new document's documentInit relay (it is
// enablement-gated and can race a fast local load): the AI panel's
// text/chat refer to the outgoing document - drop them now
this.invalidateAiState(true);
// The new document renders unrotated; without this the stale value
// makes a consumer's [rotation] re-set a silent no-op
this._rotation = 0;
// The iframe reloads fresh: discard in-flight messages and queued
// actions, and reset readiness so configuration re-applies on the
// new load's postmessage-ready handshake.
this.rejectPendingMessages("PDF source changed");
this.actionQueueManager.reset();
this.initialConfigQueued = false;
this.isPostMessageReady = false;
this.postMessageReadiness = 0;
this.loadPdf();
}
return; // pdfSrc change requires full reload, skip other change processing
}
if (this.isPostMessageReady &&
this.PDFViewerApplication?.initialized) {
this.applyChanges(changes);
}
else {
// Coalesce per property - only the latest value matters once ready.
// Initial bindings (firstChange) are excluded: the batched 'configure'
// snapshot reads live property values when the viewer becomes ready,
// so replaying them here would only duplicate traffic (and an initial
// locale binding would trigger a spurious boot-time refresh).
for (const key of Object.keys(changes)) {
if (!changes[key].firstChange) {
this.pendingChanges[key] = changes[key];
}
}
}
}
ngOnDestroy() {
// Abort any in-flight AI request (potentially a 100k-char prompt)
this.aiAbort?.abort();
// Remove the window message listener - without this every destroyed
// instance stays rooted forever and keeps processing viewer messages
window.removeEventListener("message", this.messageHandler);
// Clean up PDF.js event listeners
this.teardownPdfJsEventBindings();
// Settle in-flight promises and queued actions so consumer awaits don't
// hang forever
this.rejectPendingMessages("Viewer destroyed");
this.actionQueueManager.clearQueues();
// Release page-overlay embedded views
this.destroyPageOverlays();
// Clean up URL
this.releaseUrl?.();
}
// #endregion
// #region Message Handling Methods
generateMessageId() {
return `msg_${++this.messageIdCounter}_${Date.now()}`;
}
sendControlMessage(action, payload) {
return new Promise((resolve, reject) => {
const messageId = this.generateMessageId();
const message = {
type: "control-update",
action,
payload,
id: messageId,
timestamp: Date.now(),
};
this.pendingMessages.set(messageId, { resolve, reject });
// Send message to iframe - verify accessibility (event-driven check).
// targetOrigin "/" restricts delivery to our own origin (the viewer
// assets are same-origin), matching the wrapper's outgoing direction.
if (this.isIframeAccessible()) {
this.iframe.nativeElement.contentWindow.postMessage(message, "/");
}
else {
this.pendingMessages.delete(messageId);
reject(new Error("Iframe not available"));
}
});
}
// Wrapper for sendControlMessage that re-queues actions on iframe unavailability.
// The SAME action object (id, resolver) is re-queued so its status and the
// caller's promise track the retry, with a bounded retry count.
async sendControlMessageWithRequeue(actionObj) {
try {
return await this.sendControlMessage(actionObj.action, actionObj.payload);
}
catch (error) {
// If iframe is not available, re-queue the action for retry when the
// iframe becomes available or readiness increases
if (error instanceof Error &&
error.message === "Iframe not available" &&
(actionObj.retries = (actionObj.retries ?? 0) + 1) <= 3) {
actionObj.requeued = true;
this.actionQueueManager.queueAction(actionObj, actionObj.level ?? this.getRequiredReadinessLevel(actionObj.action));
// Trigger processing if iframe becomes available soon (event-driven, no polling)
Promise.resolve().then(() => {
if (this.isIframeAccessible() && this.isPostMessageReady) {
this.actionQueueManager.processQueuedActions();
}
});
}
// Re-throw to maintain error handling in ActionQueueManager
throw error;
}
}
// Event-driven iframe accessibility check (no polling, trust-based)
isIframeAccessible() {
return !!(this.iframe &&
this.iframe.nativeElement &&
this.iframe.nativeElement.contentWindow);
}
// Process actions on every postmessage-ready (the wrapper announces each
// readiness increase: levels 3, 4 and 5 of a single load)
processPostMessageReadyActions() {
this.actionQueueManager.updateReadiness(this.postMessageReadiness);
this.actionQueueManager.processQueuedActions();
// Apply the configuration snapshot ONCE per document load. The flag is
// reset when the iframe navigates (pdfSrc change / refresh), which is
// what makes reloads reconfigure the fresh viewer.
if (!this.initialConfigQueued) {
this.initialConfigQueued = true;
this.queueAllConfigurations();
}
// Apply any pending changes that occurred before PostMessage API was ready
this.applyPendingChanges();
}
handleControlResponse(response) {
const pendingMessage = this.pendingMessages.get(response.id);
if (pendingMessage) {
this.pendingMessages.delete(response.id);
if (response.success) {
pendingMessage.resolve(response);
}
else {
pendingMessage.reject(new Error(response.error || "Unknown error"));
}
}
}
// Named handler so ngOnDestroy can remove it. Arrow field keeps `this` bound.
messageHandler = (event) => {
// Only accept messages from this component's own viewer iframe. This
// prevents cross-talk between multiple viewer instances on one page and
// spoofed messages from unrelated windows.
if (!this.iframe?.nativeElement?.contentWindow ||
event.source !== this.iframe.nativeElement.contentWindow ||
!event.data) {
return;
}
switch (event.data.type) {
case "control-response":
this.handleControlResponse(event.data);
return;
case "ng2-pdfjs-viewer-security-warning":
this.securityWarning = {
message: event.data.message,
originalUrl: event.data.originalUrl,
currentUrl: event.data.currentUrl,
};
this.cdr.markForCheck();
return;
case "postmessage-ready":
this.isPostMessageReady = true;
this.postMessageReadiness = event.data.readiness || 0;
// Verify iframe is accessible before processing actions (event-driven readiness check)
// This prevents "Iframe not available" errors when dialog reopens quickly
if (this.isIframeAccessible()) {
this.processPostMessageReadyActions();
}
else {
// Defer to the microtask queue - handles Material Dialog lifecycle timing
Promise.resolve().then(() => {
if (this.isIframeAccessible()) {
this.processPostMessageReadyActions();
}
});
}
return;
case "state-change":
this.handleStateChangeNotification(event.data);
return;
case "event-notification":
this.handleEventNotification(event.data);
return;
case "host-request":
// Wrapper-initiated round-trip (signature storage hooks)
void this.handleHostRequest(event.data);
return;
}
};
// Serve a wrapper-initiated request against the host-side hooks and post
// the result back. Errors are returned (not thrown) so the wrapper's
// pending promise always settles.
async handleHostRequest(request) {
const respond = (data, error) => {
this.iframe?.nativeElement?.contentWindow?.postMessage({
type: "host-response",
requestId: request.requestId,
data,
error: error ?? null,
}, "/");
};
const storage = this.signatureStorage;
if (!storage) {
respond(null, "No signatureStorage hook configured");
return;
}
try {
switch (request.action) {
case "signature-storage-get-all":
respond((await storage.loadAll()) ?? {});
return;
case "signature-storage-save":
await storage.save(request.payload?.uuid, request.payload?.data);
respond(true);
return;
case "signature-storage-delete":
await storage.delete(request.payload?.uuid);
respond(true);
return;
default:
respond(null, `Unknown host request: ${request.action}`);
}
}
catch (e) {
respond(null, e?.message || "signatureStorage hook failed");
}
}
setupMessageListener() {
window.addEventListener("message", this.messageHandler);
}
rejectPendingMessages(reason) {
this.pendingMessages.forEach(({ reject }) => reject(new Error(reason)));
this.pendingMessages.clear();
}
handleStateChangeNotification(notification) {
const { property, value, source } = notification;
// Internal loading overlay control is system-driven and must be handled unconditionally
if (property === "loading") {
this.isLoading = !!value;
// Clear error state when loading starts
if (value) {
this.hasError = false;
this.currentErrorMessage = "";
}
// Trigger change detection for OnPush scenarios (PostMessage runs outside Angular zone)
this.cdr.markForCheck();
return;
}
// Two-way [(formData)] sync from user edits in form widgets. Synthetic
// events from our own set-form-data also land here - the equality check
// suppresses the echo.
if (property === "formData") {
if (!shallowEquals(value, this._formData)) {
this._formData = value ?? {};
this.formDataChange.emit(this._formData);
this.cdr.markForCheck();
}
return;
}
// Internal error state management
if (property === "error") {
this.hasError = !!value;
if (value && typeof value === "string") {
this.currentErrorMessage = this.composeErrorMessage(value);
this.updateErrorTemplateData();
}
this.cdr.markForCheck();
return;
}
// Only process user-initiated changes; value-equality checks below are
// the echo suppression for our own programmatic updates
if (source !== "user") {
return;
}
switch (property) {
case "cursor":
if (this._cursor !== value) {
this._cursor = PropertyTransformers.transformCursor.fromViewer(value);
this.cursorChange.emit(this._cursor);
}
break;
case "scroll":
if (this._scroll !== value) {
this._scroll = PropertyTransformers.transformScroll.fromViewer(value);
this.scrollChange.emit(this._scroll);
}
break;
case "spread":
if (this._spread !== value) {
this._spread = PropertyTransformers.transformSpread.fromViewer(value);
this.spreadChange.emit(this._spread);
}
break;
case "pageMode":
if (this._pageMode !== value) {
this._pageMode = PropertyTransformers.transformPageMode.fromViewer(value);
this.pageModeChange.emit(this._pageMode);
}
break;
case "zoom":
case "rotation":
// Intentionally ignored: the direct eventBus bindings in
// bindToPdfJsEventBus are the single channel for zoom/rotation (the
// wrapper's copy reports a differently-formatted value).
break;
default:
if (this.diagnosticLogs) {
console.log(`PdfJsViewer: Unknown state change property: ${property}`);
}
}
}
// Apply the documented errorMessage/errorAppend semantics to the raw
// viewer error before display
composeErrorMessage(raw) {
if (this.errorMessage) {
return this.errorAppend ? `${raw} ${this.errorMessage}` : this.errorMessage;
}
return raw;
}
handleEventNotification(notification) {
const { eventName, eventData } = notification;
if (typeof eventName !== "string" || eventName.length === 0) {
return;
}
// New document: the AI panel's extracted text and chat refer to the old
// one - drop them. (Falls through to the generic emitter.)
if (eventName === "documentInit") {
this.invalidateAiState(true);
this.cdr.markForCheck();
}
// Page add/delete/reorder invalidates extracted text and its page
// numbers, but the chat history is still about this document.
if (eventName === "pagesEdited") {
this.aiDocText = undefined;
}
// A failed document load means queued document-gated actions can never
// run - settle them (and fail-fast later dispatches via the latch) so
// consumer awaits don't hang forever.
if (eventName === "documentError") {
this.documentLoadFailed = true;
this.actionQueueManager.failDocumentActions("Document failed to load: " + (eventData?.message || "unknown error"));
}
// Any sign of a (new) document coming up lifts the latch
if (eventName === "documentInit" || eventName === "pagesInit") {
this.documentLoadFailed = false;
}
// Mount the per-page overlay template as pages (re-)render. PDF.js may
// drop appended children on re-render; mounting is idempotent and moves
// the same embedded-view nodes back in.
if (eventName === "pageRendered" && this.pageOverlayTpl) {
const pageNumber = eventData?.pageNumber;
if (typeof pageNumber === "number") {
this.mountPageOverlay(pageNumber);
}
// fall through to the generic emitter below
}
// Two-way [(annotationEditor)] sync: the wrapper relays every editor mode
// switch, including echoes of our own dispatches - only real changes
// (user toolbar clicks) update the property and emit.
if (eventName === "annotationEditorModeChange") {
const mode = eventData?.mode;
if (mode && mode !== this._annotationEditor) {
this._annotationEditor = mode;
this.annotationEditorChange.emit(mode);
this.cdr.markForCheck();
}
return;
}
// 'documentError' -> this.onDocumentError, etc.
const emitter = this["on" + eventName.charAt(0).toUpperCase() + eventName.slice(1)];
if (emitter instanceof EventEmitter) {
emitter.emit(eventData ?? undefined);
}
else if (this.diagnosticLogs) {
console.log(`PdfJsViewer: Unknown event notification: ${eventName}`);
}
}
// #endregion
// #region Property Mapping and Update Methods
applyChanges(changes) {
let needsRefresh = false;
for (const propertyName of Object.keys(changes)) {
const change = changes[propertyName];
if (change.currentValue === change.previousValue) {
continue;
}
// PDF.js applies locale only before initialization - reload to switch
if (propertyName === "locale" ||
(propertyName === "viewerConfig" &&
change.currentValue?.locale !== change.previousValue?.locale)) {
needsRefresh = true;
if (propertyName === "locale") {
continue;
}
}
// Init-time PDF.js options ride the viewer URL and are read before the
// postMessage channel exists - a change requires reloading the viewer
if (propertyName === "pdfJsOptions" ||
propertyName === "enableSignatureEditor" ||
propertyName === "enableCommentEditor" ||
propertyName === "enablePageEditing" ||
propertyName === "pageColors") {
needsRefresh = true;
continue;
}
if (DOCUMENT_LOAD_PROPS.has(propertyName) ||
SETTER_DISPATCHED_PROPS.has(propertyName)) {
continue;
}
// Config-object inputs: their setters already copied the values onto
// the individual properties - propagate those to the viewer. Keys the
// config never set stay undefined and are skipped. Same-content objects
// (fresh references from getter bindings) are not real changes.
const fanout = CONFIG_FANOUT[propertyName];
if (fanout) {
if (shallowEquals(change.currentValue, change.previousValue)) {
continue;
}
for (const prop of fanout) {
const entry = REGISTRY_BY_PROP[prop];
const v = entry?.get ? entry.get(this) : this[prop];
if (v !== undefined) {
this.dispatchRegisteredProperty(prop, v);
}
}
continue;
}
this.dispatchRegisteredProperty(propertyName, change.currentValue);
}
if (needsRefresh) {
this.refresh();
}
}
// Dispatch one registry-backed property to the viewer. Without an explicit
// value the current (setter-normalized) property value is used.
dispatchRegisteredProperty(prop, value) {
const entry = REGISTRY_BY_PROP[prop];
if (!entry) {
return; // not viewer-backed (handled component-side or via URL)
}
const v = value !== undefined ? value : entry.get ? entry.get(this) : this[prop];
this.dispatchAction(entry.action, entry.payload ? entry.payload(v, this) : v, "property-change");
}
applyPendingChanges() {
// Only apply pending changes if PostMessage API is ready
if (!this.isPostMessageReady) {
return;
}
const changes = this.pendingChanges;
this.pendingChanges = {};
if (Object.keys(changes).length > 0) {
this.applyChanges(changes);
}
}
// #endregion
// #region PDF.js Event Binding Methods
/**
* Waits for the PDF.js viewer to be ready, and binds the the event bus.
*/
bindToPdfJsEventBus() {
// Store the event listener reference so we can remove it later
const webviewerLoadedHandler = (event) => {
// For same-origin embeds PDF.js dispatches 'webviewerloaded' on the
// PARENT document, so this handler hears the event from EVERY viewer
// iframe on the page. Only react to our own iframe's dispatch -
// otherwise instance A re-binds (and duplicates) its eventBus handlers
// whenever instance B loads, multiplying every relayed event and
// auto-action.
const source = event?.detail?.source;
if (source && source !== this.iframe?.nativeElement?.contentWindow) {
return;
}
if (this.diagnosticLogs)
console.debug("PdfJsViewer: webviewerloaded event received");
// Set locale immediately when PDF.js is loaded but before it initializes
// https://github.com/mozilla/pdf.js/issues/11829#issuecomment-617668679
if (this.locale && this.iframe?.nativeElement?.contentWindow) {
try {
const iframeWindow = this.iframe.nativeElement.contentWindow;
if (iframeWindow.PDFViewerApplicationOptions) {
iframeWindow.PDFViewerApplicationOptions.set("localeProperties", {
lang: this.locale,
});
if (this.diagnosticLogs)
console.debug(`PdfJsViewer: Locale set to ${this.locale} before initialization`);
}
}
catch (error) {
if (this.diagnosticLogs)
console.debug("PdfJsViewer: Could not set locale before initialization:", error);
}
}
if (!this.PDFViewerApplication) {
if (this.diagnosticLogs)
console.debug("PdfJsViewer: Viewer not yet (or no longer) available, events can not yet be bound.");
return;
}
// https://github.com/mozilla/pdf.js/issues/9527
this.PDFViewerApplication.initializedPromise.then(() => {
// Apply any pending changes that occurred before initialization
this.applyPendingChanges();
const eventBus = this.PDFViewerApplication.eventBus;
const handlers = {
documentloaded: () => {
this.documentLoaded = true;
if (this.diagnosticLogs)
console.debug("PdfJsViewer: The document has now been loaded!");
this.onDocumentLoad.emit();
// Project onPagesInit here. PDF.js fires the real 'pagesinit' once,
// BEFORE this handler map finishes registering (it lands between
// 'pagesinit' and 'scalechanging'), and the postMessage wrapper's own
// pagesInit relay is wired on 'documentloaded' too late for the
// one-shot - so onPagesInit never fired and the signals entry point's
// loaded()/totalPages() stayed empty. 'documentloaded' is reliably
// caught (it drives onDocumentLoad) and pagesCount is set by now.
const app = this.PDFViewerApplication;
const pagesCount = app?.pagesCount ?? app?.pdfDocument?.numPages;
if (typeof pagesCount === "number" && pagesCount > 0) {
this.onPagesInit.emit({ pagesCount });
}
// Queue auto-actions with the property values current at THIS load
this.queueAutoActionsForDocumentLoad();
// Execute all queued auto-actions
this.actionQueueManager.onDocumentLoaded();
},
pagesloaded: () => {
// Auto-print here: the PDF is fully ready for printing
if (this.printOnLoad === true) {
this.dispatchAction("trigger-print", true, "initial-load");
}
},
beforeprint: () => this.onBeforePrint.emit(),
afterprint: () => this.onAfterPrint.emit(),
pagechanging: (event) => {
this._page = event.pageNumber;
this.onPageChange.emit(event.pageNumber);
},
rotationchanging: (event) => {
this._rotation = PropertyTransformers.transformRotation.fromViewer(event.pagesRotation);
const newRotation = {
rotation: event.pagesRotation,
page: event.pageNumber,
};
this.onRotationChange.emit(newRotation);
},
scalechanging: (event) => {
// Named zooms (page-fit, page-width, ...) arrive as presetValue
// alongside the resolved numeric scale; prefer the name so
// zoomChange round-trips named values instead of leaking numbers.
const normalizedZoom = typeof event.presetValue === "string" && event.presetValue
? event.presetValue
: PropertyTransformers.transformZoom.fromViewer(event.scale);
// Value-echo suppression: emit only when the value actually changed
if (this._zoom !== normalizedZoom) {
this._zoom = normalizedZoom;
this.zoomChange.emit(normalizedZoom);
}
this.onScaleChange.emit(event.scale);
},
};
// The eventBus invokes these from the iframe realm, outside the
// parent's NgZone - without re-entering the zone, consumer bindings
// driven by these outputs ((onPageChange), [(zoom)], ...) never
// schedule change detection in zone-based apps.
const zonedHandlers = {};
for (const eventName of Object.keys(handlers)) {
const handler = handlers[eventName];
zonedHandlers[eventName] = (event) => {
if (this.ngZone) {
this.ngZone.run(() => handler(event));
}
else {
handler(event);
}
};
}
// Store the registered (zoned) functions so teardown removes the
// exact listeners that were added
this.pdfEventHandlers = zonedHandlers;
for (const eventName of Object.keys(zonedHandlers)) {
eventBus.on(eventName, zonedHandlers[eventName]);
}
});
};
// Store the handler reference for cleanup / re-binding on refresh()
this.webviewerLoadedHandler = webviewerLoadedHandler;
document.addEventListener("webviewerloaded", webviewerLoadedHandler);
}
teardownPdfJsEventBindings() {
if (this.webviewerLoadedHandler) {
document.removeEventListener("webviewerloaded", this.webviewerLoadedHandler);
this.webviewerLoadedHandler = undefined;
}
if (this.pdfEventHandlers) {
const eventBus = this.PDFViewerApplication?.eventBus;
if (eventBus) {
for (const eventName of Object.keys(this.pdfEventHandlers)) {
eventBus.off(eventName, this.pdfEventHandlers[eventName]);
}
}
this.pdfEventHandlers = undefined;
}
}
// #endregion
// #region Configuration and Action Queue Methods
// Snapshot every registry-backed property and queue it for the (re)loaded
// viewer, then enable wrapper-side event notifications.
queueAllConfigurations() {
// The whole configuration snapshot ships as ONE batched 'configure'
// message per readiness level (instead of ~40 individual messages).
// The wrapper replays each step through its normal control dispatch.
const batches = new Map();
const add = (level, action, payload) => {
let steps = batches.get(level);
if (!steps) {
batches.set(level, (steps = []));
}
steps.push({ action, payload });
};
for (const entry of PROPERTY_REGISTRY) {
if (entry.init === false) {
continue;
}
const value = entry.get ? entry.get(this) : this[entry.prop];
const send = entry.init === "always" ||
(entry.init === "truthy" && !!value) ||
(entry.init === "defined" && value !== undefined) ||
(entry.init === "true" && value === true) ||
(entry.init === "nonempty" &&
typeof value === "string" &&
value.trim() !== "");
if (send) {
add(entry.level, entry.action, entry.payload ? entry.payload(value, this) : value);
}
}
for (const action of ENABLE_EVENT_ACTIONS) {
add(this.getRequiredReadinessLevel(action), action, true);
}
// Idle is opt-in: enabling it installs document-wide activity listeners
// in the iframe, so only pay for it when someone listens
if (hasObservers(this.onIdle)) {
add(this.getRequiredReadinessLevel("enable-idle"), "enable-idle", true);
}
// Ascending level order so lower-readiness batches apply first
for (const level of [...batches.keys()].sort((a, b) => a - b)) {
this.dispatchAction("configure", batches.get(level), "initial-load", level);
}
}
queueAutoActionsForDocumentLoad() {
// Use universal dispatcher for auto-actions
if (this.downloadOnLoad === true) {
this.dispatchAction("trigger-download", true, "initial-load");
}
if (this.showLastPageOnLoad === true) {
this.dispatchAction("go-to-last-page", true, "initial-load");
}
// Auto-print is handled in the pagesloaded handler for timing reasons
}
refresh() {
// Needs to be invoked for external window or when needs to reload pdf
// The reload swaps the document out from under the AI panel - drop its
// extracted text/chat and abandon any in-flight request
this.invalidateAiState(true);
// Remove stale PDF.js bindings, then re-arm webviewerloaded so the
// reloaded viewer's events bind again (they used to die after refresh)
this.teardownPdfJsEventBindings();
this.bindToPdfJsEventBus();
// Settle in-flight messages and queued actions from the old load
this.rejectPendingMessages("Viewer reloading");
this.actionQueueManager.reset();
// Reset PostMessage readiness state
this.isPostMessageReady = false;
this.postMessageReadiness = 0;
this.initialConfigQueued = false;
// Reload the PDF - this will trigger queueAllConfigurations() when PostMessage API is ready
this.loadPdf();
}
// Public method for external control messages
sendViewerControlMessage(action, payload) {
return this.sendControlMessage(action, payload);
}
// #region Public Methods for On-Demand Actions
triggerDownload() {
// Use universal dispatcher for user interactions - now always returns Promise<ActionExecutionResult>
return this.dispatchAction("trigger-download", true, "user-interaction");
}
triggerPrint() {
// Use universal dispatcher for user interactions - now always returns Promise<ActionExecutionResult>
return this.dispatchAction("trigger-print", true, "user-interaction");
}
setPage(page) {
// Use universal dispatcher for user interactions - now always returns Promise<ActionExecutionResult>
return this.dispatchAction("set-page", page, "user-interaction");
}
setZoom(zoom) {
// Use universal dispatcher for user interactions - now always returns Promise<ActionExecutionResult>
return this.dispatchAction("set-zoom", zoom, "user-interaction");
}
goToLastPage() {
// Use universal dispatcher for user interactions - now always returns Promise<ActionExecutionResult>
return this.dispatchAction("go-to-last-page", true, "user-interaction");
}
setCursor(cursor) {
// Use universal dispatcher for user interactions
return this.dispatchAction("set-cursor", cursor, "user-interaction");
}
setScroll(scroll) {
// Use universal dispatcher for user interactions
return this.dispatchAction("set-scroll", scroll, "user-interaction");
}
setSpread(spread) {
// Use universal dispatcher for user interactions
return this.dispatchAction("set-spread", spread, "user-interaction");
}
triggerRotation(direction) {
// Use universal dispatcher for user interactions
const action = direction === "cw" ? "trigger-rotate-cw" : "trigger-rotate-ccw";
return this.dispatchAction(action, true, "user-interaction");
}
goToPage(page) {
// Alias for setPage for backward compatibility
return this.setPage(page);
}
// #endregion
// #region Annotation & Search API
/**
* Serialized state of every annotation created or modified in the editor.
* Send this to a server to persist user annotations.
*/
async getAnnotations() {
const result = await this.dispatchAction("get-annotations", null, "user-interaction");
if (!result.success) {
throw new Error(result.error || "getAnnotations failed");
}
return result.data ?? [];
}
/**
* Restore annotations previously exported with getAnnotations() back into
* the editor. Each annotation is rebuilt on its own page; annotations for
* pages that haven't rendered yet apply automatically as those pages
* render (counted in `pending`). Items with an invalid pageIndex for the
* current document are skipped (counted in `rejected`). Calling this twice
* with the same payload creates duplicates - restore is additive. Note:
* stamp images cannot round-trip (their bitmaps are not serializable).
*/
async setAnnotations(annotations) {
const result = await this.dispatchAction("set-annotations", annotations ?? [], "user-interaction");
if (!result.success) {
throw new Error(result.error || "setAnnotations failed");
}
return result.data ?? { restored: 0, pending: 0, rejected: 0 };
}
/**
* The current document - including annotation edits and filled form
* fields - as a Blob, ready for upload or download.
*/
async getDocumentAsBlob() {
const result = await this.dispatchAction("save-document", null, "user-interaction");
if (!result.success || !result.data?.bytes) {
throw new Error(result.error || "getDocumentAsBlob failed");
}
return new Blob([result.data.bytes], { type: "application/pdf" });
}
/**
* Programmatic full-text search. Resolves with totals, per-page match
* counts and the pages containing matches; matches are highlighted in the
* viewer (highlightAll defaults to true).
*/
async search(query, options) {
const result = await this.dispatchAction("search", { query, ...(options ?? {}) }, "user-interaction");
if (!result.success) {
throw new Error(result.error || "search failed");
}
return result.data;
}
/** Move the search selection to the next match. */
async searchNext() {
const result = await this.dispatchAction("search-next", null, "user-interaction");
if (!result.success) {
throw new Error(result.error || "searchNext failed");
}
return result.data;
}
/** Move the search selection to the previous match. */
async searchPrevious() {
const result = await this.dispatchAction("search-previous", null, "user-interaction");
if (!result.success) {
throw new Error(result.error || "searchPrevious failed");
}
return result.data;
}
/** Clear search highlights and forget the active query. */
clearSearch() {
return this.dispatchAction("clear-search", null, "user-interaction");
}
// Embedded views for pageOverlayTpl, keyed by page number. Views are
// attached to ApplicationRef so bindings inside stay live.
overlayViews = new Map();
mountPageOverlay(pageNumber, knownPageEl) {
if (!this.pageOverlayTpl || this.externalWindow)
return;
const doc = this.iframe?.nativeElement?.contentDocument;
// Callers iterating already-rendered pages pass the element they hold, so
// we skip re-finding it by selector (saves one DOM query per page on large
// documents); the per-page render path passes nothing and looks it up.
const pageEl = knownPageEl ??
doc?.querySelector(`.pdfViewer .page[data-page-number="${pageNumber}"]`);
if (!pageEl || pageEl.querySelector(":scope > .ng2-page-overlay")) {
return;
}
let view = this.overlayViews.get(pageNumber);
if (!view) {
view = this.pageOverlayTpl.createEmbeddedView({ $implicit: pageNumber });
this.appRef?.attachView(view);
view.detectChanges();
this.overlayViews.set(pageNumber, view);
}
const wrapper = doc.createElement("div");
wrapper.className = "ng2-page-overlay";
for (const node of view.rootNodes) {
wrapper.appendChild(node);
}
pageEl.appendChild(wrapper);
}
destroyPageOverlays() {
for (const view of this.overlayViews.values()) {
this.appRef?.detachView(view);
view.destroy();
}
this.overlayViews.clear();
// Destroying the views removes their nodes but not the wrapper divs;
// leftover wrappers would also block the remount guard in mountPageOverlay.
const doc = this.iframe?.nativeElement?.contentDocument;
doc
?.querySelectorAll(".ng2-page-overlay")
.forEach((el) => el.remove());
}
// Mount overlays on every page div that already exists (used when the
// template input is set after pages have rendered, e.g. a toggle).
mountOverlaysOnRenderedPages() {
const doc = this.iframe?.nativeElement?.contentDocument;
if (!doc)
return;
doc
.querySelectorAll(".pdfViewer .page[data-page-number]")
.forEach((el) => {
const pageNumber = Number(el.getAttribute("data-page-number"));
if (pageNumber > 0) {
this.mountPageOverlay(pageNumber, el);
}
});
}
/**
* Plain text of the document (or a 1-based page range), extracted from the
* PDF.js text layer. The raw material for BYO-AI chat/summarize flows.
*/
async getDocumentText(from, to) {
// The document may not have finished loading the instant this is called
// (e.g. an AI 'ask' fired immediately after the viewer appears). Wait for
// 'documentloaded' so we don't extract empty text; fall back on timeout.
if (!this.documentLoaded) {
await this.waitForDocumentLoad(15000);
}
const result = await this.dispatchAction("get-document-text", { from, to }, "user-interaction");
if (!result.success) {
throw new Error(result.error || "getDocumentText failed");
}
return result.data ?? [];
}
/**
* Resolve once the current document has loaded, or after timeoutMs (in which
* case callers proceed with whatever the viewer can provide). Never rejects.
*/
waitForDocumentLoad(timeoutMs) {
if (this.documentLoaded)
return Promise.resolve();
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled)
return;
settled = true;
sub.unsubscribe();
clearTimeout(timer);
resolve();
};
const sub = this.onDocumentLoad.subscribe(() => finish());
const timer = setTimeout(finish, timeoutMs);
});
}
/**
* Read the document aloud from the current (or given) page using the
* browser's speech synthesis. Progress arrives on onReadAloudStateChange.
*/
startReadAloud(options) {
return this.dispatchAction("read-aloud", { command: "start", ...(options ?? {}) }, "user-interaction");
}
pauseReadAloud() {
return this.dispatchAction("read-aloud", { command: "pause" }, "user-interaction");
}
resumeReadAloud() {
return this.dispatchAction("read-aloud", { command: "resume" }, "user-interaction");
}
stopReadAloud() {
return this.dispatchAction("read-aloud", { command: "stop" }, "user-interaction");
}
/**
* Ask the built-in AI panel a question programmatically (same path the
* panel's input uses). Requires [aiAssistantConfig]. Document text is
* extracted once per document and reused across questions.
*/
async aiAsk(question) {
const q = (question || "").trim();
const config = this.aiAssistantConfig;
if (!q || this.aiBusy || !config) {
return;
}
this.aiBusy = true;
const generation = this.aiGeneration;
this.aiAbort = new AbortController();
const signal = this.aiAbort.signal;
this.aiMessages.push({ role: "user", content: q, parts: [{ text: q }] });
// Placeholder assistant turn we stream tokens into as they arrive.
const assistant = { role: "assistant", content: "", parts: [] };
this.aiMessages.push(assistant);
this.cdr.markForCheck();
try {
if (!this.aiClient || this.aiClientConfig !== config) {
this.aiClient = new PdfAiAssistant(config);
this.aiClientConfig = config;
}
if (!this.aiDocText) {
this.aiDocText = await this.getDocumentText();
}
const history = this.aiMessages
.slice(0, -2)
.filter((m) => !m.error)
.map((m) => ({ role: m.role, content: m.content }));
const answer = await this.aiClient.ask(q, this.aiDocText, history, signal, (full) => {
if (generation !== this.aiGeneration) {
return; // stale stream - ignore late tokens
}
assistant.content = full;
assistant.parts = this.parseAiCitations(full);
this.cdr.markForCheck();
});
if (generation !== this.aiGeneration) {
return; // document changed mid-flight - stale answer
}
assistant.content = answer;
assistant.parts = this.parseAiCitations(answer);
}
catch (e) {
if (generation !== this.aiGeneration) {
return; // aborted by invalidation - already cleaned up
}
assistant.error = e?.message || "AI request failed";
}
finally {
if (generation === this.aiGeneration) {
this.aiBusy = false;
}
this.cdr.markForCheck();
}
}
// Split an answer into text runs and clickable [p.N] page citations
parseAiCitations(text) {
const parts = [];
const re = /\[p\.?\s*(\d+)\]/gi;
let last = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
parts.push({ text: text.slice(last, m.index) });
}
parts.push({ page: parseInt(m[1], 10) });
last = m.index + m[0].length;
}
if (last < text.length) {
parts.push({ text: text.slice(last) });
}
return parts;
}
/**
* Current AcroForm field values (field name -> value), reflecting any
* user edits. Returns {} for documents without form fields.
*/
async getFormData() {
const result = await this.dispatchAction("get-form-data", null, "user-interaction");
if (!result.success) {
throw new Error(result.error || "getFormData failed");
}
return (result.data ?? {});
}
/** Set a single form field by name. */
setFormField(name, value) {
return this.dispatchAction("set-form-field", { name, value }, "user-interaction");
}
// #endregion
// Action queue management methods
getActionStatus(actionId) {
return this.actionQueueManager.getActionStatus(actionId);
}
getQueueStatus() {
return this.actionQueueManager.getQueueStatus();
}
clearActionQueue() {
this.actionQueueManager.clearQueues();
}
/**
* Enable or disable URL validation security feature
* When enabled, prevents users from modifying the file parameter in the viewer URL
* @param enabled - Whether to enable URL validation (default: true)
* @returns Promise<ActionExecutionResult>
*/
setUrlValidation(enabled = true) {
return this.dispatchAction("set-url-validation", enabled, "user-interaction");
}
/**
* Dismiss the security warning
*/
dismissSecurityWarning() {
this.securityWarning = null;
// Public API called from outside this component's CD context - mark so
// the overlay clears under OnPush/zoneless consumers
this.cdr.markForCheck();
}
// #endregion
// #region PDF Loading and URL Handling
loadPdf() {
if (!this._src)
return;
// Show spinner immediately when PDF loading starts (Issue #275).
// markForCheck: loadPdf is reachable from the public refresh() API, where
// no input-change CD pass marks this (possibly OnPush) view.
this.isLoading = true;
this.hasError = false;
this.currentErrorMessage = "";
// A new load lifts the failed-document latch (the documentInit relay
// also clears it, but that is enablement-gated and can race fast loads)
this.documentLoadFailed = false;
// Text isn't extractable until the new document finishes loading.
this.documentLoaded = false;
this.cdr.markForCheck();
if (!this.setupExternalWindow()) {
return; // popup blocked - nothing to navigate
}
// Authenticated fetch path: the viewer iframe cannot attach headers to
// its own request, so the component downloads the document and feeds the
// viewer a local blob instead.
if (typeof this._src === "string" &&
(this.httpHeaders || this.withCredentials)) {
void this.fetchPdfWithAuth(this._src);
return;
}
const fileUrl = this.createFileUrl();
const viewerUrl = this.buildViewerUrl(fileUrl);
this.navigateToViewer(viewerUrl);
}
async fetchPdfWithAuth(url) {
const loadToken = ++this.authLoadToken;
try {
const response = await fetch(url, {
headers: this.httpHeaders ?? {},
credentials: this.withCredentials ? "include" : "same-origin",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
let blob;
if (response.body && hasObservers(this.onProgress)) {
const total = Number(response.headers.get("content-length")) || 0;
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
chunks.push(value);
loaded += value.byteLength;
this.onProgress.emit({ loaded, total });
}
blob = new Blob(chunks, { type: "application/pdf" });
}
else {
blob = await response.blob();
}
if (loadToken !== this.authLoadToken) {
return; // pdfSrc changed while downloading - drop the stale result
}
// Hand the bytes to the normal blob path without touching the
// consumer's pdfSrc (it stays the original string URL).
this.releaseUrl?.();
const objectUrl = URL.createObjectURL(blob);
this.releaseUrl = () => URL.revokeObjectURL(objectUrl);
const viewerUrl = this.buildViewerUrl(encodeURIComponent(objectUrl));
this.navigateToViewer(viewerUrl);
}
catch (error) {
if (loadToken !== this.authLoadToken) {
return;
}
const message = error instanceof Error ? error.message : String(error);
this.isLoading = false;
this.hasError = true;
this.currentErrorMessage = this.composeErrorMessage(`Failed to fetch PDF: ${message}`);
this.updateErrorTemplateData();
this.onDocumentError.emit({
message: this.currentErrorMessage,
name: "FetchError",
source: url,
});
this.cdr.markForCheck();
}
}
// Returns false when an external window is required but could not be opened
setupExternalWindow() {
if (!this.externalWindow)
return true;
if (typeof this.viewerTab === "undefined" || this.viewerTab.closed) {
this.viewerTab = window.open("", this.target, this.externalWindowOptions || "");
if (this.viewerTab == null) {
// Always surface this - it's an actionable consumer-facing failure
console.error("ng2-pdfjs-viewer: 'externalWindow = true' requires pop-ups to be enabled.");
return false;
}
if (this.showSpinner) {
this.renderLoadingSpinner();
}
}
return true;
}
renderLoadingSpinner() {
this.viewerTab.document.write(`
<style>
.loader {
position: fixed;
left: 40%;
top: 40%;
border: 16px solid #f3f3f3;
border-radius: 50%;
border-top: 16px solid #3498db;
width: 120px;
height: 120px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<div class="loader"></div>
`);
}
createFileUrl() {
this.releaseUrl?.();
if (this._src instanceof Blob) {
const url = URL.createObjectURL(this._src);
this.releaseUrl = () => URL.revokeObjectURL(url);
return encodeURIComponent(url);
}
else if (this._src instanceof Uint8Array) {
// A typed-array view is a valid BlobPart; using it directly respects
// byteOffset/byteLength (the raw .buffer of a subarray would not)
const blob = new Blob([this._src], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
this.releaseUrl = () => URL.revokeObjectURL(url);
return encodeURIComponent(url);
}
else {
return this._src;
}
}
buildViewerUrl(fileUrl) {
const base = this.viewerFolder
? `${this.viewerFolder}/web/viewer.html`
: `assets/pdfjs/web/viewer.html`;
// Control params go in the query string first; the file URL is appended
// LAST. A consumer's file URL may end in a hash fragment that PDF.js reads
// for navigation (e.g. doc.pdf#search=foo, #page=2). Anything appended
// after the file param would land inside that fragment instead of the
// query string - so file trails the whole URL, leaving the hash where
// PDF.js looks for it. (#305)
let viewerUrl = `${base}?urlValidation=${this.urlValidation === false ? 0 : 1}`;
if (typeof this.viewerId !== "undefined") {
viewerUrl += `&viewerId=${this.viewerId}`;
}
// Init-time PDF.js options (signature editor, page colors, passthrough).
// These are read by PDF.js during initialize() - before the postMessage
// channel exists - so they ride the viewer URL and are applied by the
// wrapper at 'webviewerloaded' (after module eval, before run()). The
// wrapper validates every key against its allowlist.
const initOptions = this.collectInitTimeOptions();
if (Object.keys(initOptions).length > 0) {
viewerUrl += `&pjsOptions=${encodeURIComponent(JSON.stringify(initOptions))}`;
}
// Cache-bust in Angular dev mode so editing pdfjs assets takes effect.
// (Angular's own signal, not a hostname/port heuristic - production apps
// served from localhost keep clean, cacheable viewer URLs.)
if (isDevMode()) {
viewerUrl += `&_t=${Date.now()}`;
}
// File last (see above): its optional #hash fragment must trail the URL.
viewerUrl += `&file=${fileUrl}`;
return viewerUrl;
}
// Merge the dedicated convenience inputs with the raw pdfJsOptions
// passthrough (dedicated inputs win on conflict).
collectInitTimeOptions() {
const options = {
...(this.pdfJsOptions ?? {}),
};
if (this.enableSignatureEditor) {
options["enableSignatureEditor"] = true;
}
if (this.enableCommentEditor) {
options["enableComment"] = true;
}
if (this.enablePageEditing) {
options["enableMerge"] = true;
options["enableSplitMerge"] = true;
options["enableUpdatedAddImage"] = true;
}
if (this.pageColors) {
options["forcePageColors"] = true;
options["pageColorsBackground"] = this.pageColors.background;
options["pageColorsForeground"] = this.pageColors.foreground;
}
return options;
}
navigateToViewer(viewerUrl) {
if (this.externalWindow) {
this.viewerTab.location.href = viewerUrl;
}
else {
this.iframe.nativeElement.contentWindow.location.replace(viewerUrl);
}
if (this.diagnosticLogs) {
console.debug("PdfJsViewer: loading viewer", {
viewerUrl,
pdfSrc: this.pdfSrc,
externalWindow: this.externalWindow,
viewerFolder: this.viewerFolder,
viewerId: this.viewerId,
});
}
}
// #endregion
// External-window mode has no postMessage channel (the wrapper runs in the
// popup, not the hidden iframe) - actions queue forever. Warn once instead
// of failing silently.
externalWindowWarned = false;
// Set on documentError, cleared when a new load begins: document-gated
// actions dispatched against a failed load settle immediately instead of
// queueing forever.
documentLoadFailed = false;
// True once the current document fires 'documentloaded'. getDocumentText()
// awaits this so an AI 'ask' fired before render doesn't extract empty text.
documentLoaded = false;
// Universal Action Dispatcher - ALL actions go through readiness-based queuing
dispatchAction(action, payload, source = "property-change", level) {
if (this.externalWindow && !this.externalWindowWarned) {
this.externalWindowWarned = true;
console.warn("PdfJsViewer: programmatic actions and event relays are not available " +
"in externalWindow mode - the postMessage channel only connects to " +
"the embedded iframe viewer.");
}
const requiredReadiness = level ?? this.getRequiredReadinessLevel(action);
const actionObj = {
id: `${source}-${action}-${++this.actionIdCounter}`,
action: action,
payload: payload,
level: level,
};
// Document-gated action against a load that already failed: it can never
// execute - settle now instead of queueing forever.
if (requiredReadiness === 5 &&
this.documentLoadFailed &&
!this.actionQueueManager.isDocumentLoaded) {
return Promise.resolve({
actionId: actionObj.id,
success: false,
error: "Document failed to load",
timestamp: Date.now(),
});
}
// Check if we have sufficient readiness to execute immediately
if (this.hasRequiredReadiness(requiredReadiness)) {
return this.actionQueueManager.executeAction(actionObj);
}
// Queue the action; the promise settles when it actually executes (or
// resolves with success:false if the queue is cleared first)
this.actionQueueManager.queueAction(actionObj, requiredReadiness);
return new Promise((resolve) => {
actionObj.resolver = resolve;
});
}
getRequiredReadinessLevel(action) {
return ACTION_READINESS[action] ?? 3; // default: EVENTBUS_READY
}
hasRequiredReadiness(requiredLevel) {
if (!this.isPostMessageReady)
return false;
if (requiredLevel === 5 && !this.actionQueueManager.isDocumentLoaded)
return false;
return this.postMessageReadiness >= requiredLevel;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: PdfJsViewerComponent, deps: [{ token: i0.ChangeDetectorRef }, { token: i0.ApplicationRef }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.0.1", type: PdfJsViewerComponent, isStandalone: false, selector: "ng2-pdfjs-viewer", inputs: { viewerId: "viewerId", viewerFolder: "viewerFolder", externalWindow: "externalWindow", target: "target", showSpinner: "showSpinner", downloadFileName: "downloadFileName", locale: "locale", useOnlyCssZoom: "useOnlyCssZoom", externalLinkTarget: "externalLinkTarget", rememberLastView: "rememberLastView", annotationEditor: "annotationEditor", highlightEditorColors: "highlightEditorColors", enableSignatureEditor: "enableSignatureEditor", signatureStorage: "signatureStorage", pageColors: "pageColors", pdfJsOptions: "pdfJsOptions", enableCommentEditor: "enableCommentEditor", enablePageEditing: "enablePageEditing", showToolbar: "showToolbar", chromeless: "chromeless", customToolbarTpl: "customToolbarTpl", customSidebarTpl: "customSidebarTpl", aiAssistantConfig: "aiAssistantConfig", pageOverlayTpl: "pageOverlayTpl", httpHeaders: "httpHeaders", withCredentials: "withCredentials", formData: "formData", contentProtection: "contentProtection", iframeSandbox: "iframeSandbox", diagnosticLogs: "diagnosticLogs", showOpenFile: "showOpenFile", showAnnotations: "showAnnotations", showDownload: "showDownload", showViewBookmark: "showViewBookmark", showPrint: "showPrint", showFullScreen: "showFullScreen", showFind: "showFind", downloadOnLoad: "downloadOnLoad", printOnLoad: "printOnLoad", rotateCW: "rotateCW", rotateCCW: "rotateCCW", showLastPageOnLoad: "showLastPageOnLoad", namedDest: "namedDest", errorOverride: "errorOverride", errorAppend: "errorAppend", errorMessage: "errorMessage", urlValidation: "urlValidation", customSecurityTpl: "customSecurityTpl", theme: "theme", primaryColor: "primaryColor", backgroundColor: "backgroundColor", pageBorderColor: "pageBorderColor", pageSpacing: "pageSpacing", toolbarColor: "toolbarColor", textColor: "textColor", borderRadius: "borderRadius", customCSS: "customCSS", cspNonce: "cspNonce", iframeTitle: "iframeTitle", customSpinnerTpl: "customSpinnerTpl", spinnerClass: "spinnerClass", customErrorTpl: "customErrorTpl", errorClass: "errorClass", showToolbarLeft: "showToolbarLeft", showToolbarMiddle: "showToolbarMiddle", showToolbarRight: "showToolbarRight", showSecondaryToolbarToggle: "showSecondaryToolbarToggle", showSidebar: "showSidebar", showSidebarLeft: "showSidebarLeft", showSidebarRight: "showSidebarRight", toolbarDensity: "toolbarDensity", sidebarWidth: "sidebarWidth", toolbarPosition: "toolbarPosition", sidebarPosition: "sidebarPosition", responsiveBreakpoint: "responsiveBreakpoint", controlVisibility: "controlVisibility", autoActions: "autoActions", errorHandling: "errorHandling", viewerConfig: "viewerConfig", themeConfig: "themeConfig", groupVisibility: "groupVisibility", layoutConfig: "layoutConfig", startDownload: "startDownload", startPrint: "startPrint", openFile: "openFile", download: "download", print: "print", fullScreen: "fullScreen", find: "find", viewBookmark: "viewBookmark", lastPage: "lastPage", externalWindowOptions: "externalWindowOptions", iframeBorder: "iframeBorder", zoom: "zoom", rotation: "rotation", cursor: "cursor", scroll: "scroll", spread: "spread", pageMode: "pageMode", page: "page", pdfSrc: "pdfSrc" }, outputs: { onBeforePrint: "onBeforePrint", onAfterPrint: "onAfterPrint", onDocumentLoad: "onDocumentLoad", onPageChange: "onPageChange", onScaleChange: "onScaleChange", onRotationChange: "onRotationChange", onDocumentError: "onDocumentError", onDocumentInit: "onDocumentInit", onPagesInit: "onPagesInit", onPresentationModeChanged: "onPresentationModeChanged", onOpenFile: "onOpenFile", onFind: "onFind", onUpdateFindMatchesCount: "onUpdateFindMatchesCount", onMetadataLoaded: "onMetadataLoaded", onOutlineLoaded: "onOutlineLoaded", onPageRendered: "onPageRendered", onAnnotationLayerRendered: "onAnnotationLayerRendered", onBookmarkClick: "onBookmarkClick", onIdle: "onIdle", onPasswordPrompt: "onPasswordPrompt", onAnnotationEditorStateChange: "onAnnotationEditorStateChange", onPagesEdited: "onPagesEdited", onReadAloudStateChange: "onReadAloudStateChange", onSidebarViewChanged: "onSidebarViewChanged", onLayersChanged: "onLayersChanged", onNamedAction: "onNamedAction", onDocumentProperties: "onDocumentProperties", annotationEditorChange: "annotationEditorChange", onProgress: "onProgress", formDataChange: "formDataChange", zoomChange: "zoomChange", cursorChange: "cursorChange", scrollChange: "scrollChange", spreadChange: "spreadChange", pageModeChange: "pageModeChange" }, viewQueries: [{ propertyName: "iframe", first: true, predicate: ["iframe"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: `
<div
class="ng2-pdfjs-viewer-container"
[class.ng2-has-custom-toolbar]="customToolbarTpl && !externalWindow"
[class.ng2-has-custom-sidebar]="customSidebarTpl && !externalWindow"
>
<div
class="ng2-pdfjs-custom-toolbar"
*ngIf="customToolbarTpl && !externalWindow"
>
<ng-container
[ngTemplateOutlet]="customToolbarTpl"
[ngTemplateOutletContext]="{ $implicit: this }"
></ng-container>
</div>
<div
class="ng2-pdfjs-custom-sidebar"
*ngIf="customSidebarTpl && !externalWindow"
>
<ng-container
[ngTemplateOutlet]="customSidebarTpl"
[ngTemplateOutletContext]="{ $implicit: this }"
></ng-container>
</div>
<iframe
[title]="iframeTitle || 'PDF document viewer'"
[hidden]="externalWindow || (!externalWindow && !pdfSrc)"
sandbox="allow-forms allow-scripts allow-same-origin allow-modals allow-downloads allow-popups allow-popups-to-escape-sandbox"
[class]="getIframeClasses()"
#iframe
width="100%"
height="100%"
></iframe>
<div
class="ng2-pdfjs-loading-overlay"
*ngIf="showSpinner && isLoading && !externalWindow"
[ngClass]="spinnerClass"
>
<ng-container
*ngIf="customSpinnerTpl; else defaultSpinner"
[ngTemplateOutlet]="customSpinnerTpl"
></ng-container>
<ng-template #defaultSpinner>
<div class="ng2-pdfjs-spinner-content">
<div class="ng2-pdfjs-spinner-icon"></div>
<div class="ng2-pdfjs-spinner-text">
Loading PDF...
</div>
</div>
</ng-template>
</div>
<div
class="ng2-pdfjs-error-overlay"
*ngIf="errorOverride && hasError && !externalWindow"
[ngClass]="errorClass"
>
<ng-container
*ngIf="customErrorTpl; else defaultError"
[ngTemplateOutlet]="customErrorTpl"
[ngTemplateOutletContext]="errorTemplateData"
></ng-container>
<ng-template #defaultError>
<div class="ng2-pdfjs-error-content">
<div class="ng2-pdfjs-error-icon">
⚠️
</div>
<div class="ng2-pdfjs-error-title">
Error Loading PDF
</div>
<div class="ng2-pdfjs-error-message">
{{ currentErrorMessage }}
</div>
</div>
</ng-template>
</div>
<button
type="button"
class="ng2-ai-fab"
*ngIf="aiAssistantConfig && !externalWindow"
(click)="aiPanelOpen = !aiPanelOpen"
[attr.aria-expanded]="aiPanelOpen"
aria-label="Ask AI about this document"
>
✦
</button>
<div
class="ng2-ai-panel"
*ngIf="aiAssistantConfig && aiPanelOpen && !externalWindow"
role="complementary"
aria-label="AI assistant"
>
<div class="ng2-ai-head">
<span>{{ aiAssistantConfig.title || 'Ask this document' }}</span>
<button
type="button"
(click)="aiPanelOpen = false"
aria-label="Close AI panel"
>
×
</button>
</div>
<div class="ng2-ai-msgs" aria-live="polite">
<div
class="ng2-ai-msg"
*ngFor="let m of aiMessages"
[class.ng2-ai-user]="m.role === 'user'"
>
<ng-container *ngFor="let part of m.parts">
<button
type="button"
class="ng2-ai-cite"
*ngIf="part.page; else plainPart"
(click)="setPage(part.page!)"
>
p.{{ part.page }}
</button>
<ng-template #plainPart>{{ part.text }}</ng-template>
</ng-container>
<span class="ng2-ai-error" *ngIf="m.error">{{ m.error }}</span>
</div>
<div class="ng2-ai-msg ng2-ai-busy" *ngIf="aiBusy">Thinking…</div>
</div>
<div class="ng2-ai-input">
<input
#aiq
type="text"
[placeholder]="aiAssistantConfig.placeholder || 'Ask the document…'"
[disabled]="aiBusy"
(keyup.enter)="aiAsk(aiq.value); aiq.value = ''"
aria-label="Question for the AI assistant"
/>
<button
type="button"
[disabled]="aiBusy"
(click)="aiAsk(aiq.value); aiq.value = ''"
>
Ask
</button>
</div>
</div>
<div
class="ng2-pdfjs-error-overlay"
*ngIf="securityWarning && !externalWindow"
[ngClass]="errorClass"
>
<ng-container
*ngIf="customSecurityTpl; else defaultSecurity"
[ngTemplateOutlet]="customSecurityTpl"
[ngTemplateOutletContext]="{ $implicit: securityWarning, securityWarning: securityWarning }"
></ng-container>
<ng-template #defaultSecurity>
<div class="ng2-pdfjs-error-content">
<div class="ng2-pdfjs-error-icon">
⚠️
</div>
<div class="ng2-pdfjs-error-title">
Security Warning
</div>
<div class="ng2-pdfjs-error-message">
{{ securityWarning?.message }}
</div>
</div>
</ng-template>
</div>
</div>
`, isInline: true, styles: [".ng2-pdfjs-viewer-container{position:relative;width:100%;height:100%}.ng2-pdfjs-viewer-container.ng2-has-custom-toolbar{display:flex;flex-direction:column}.ng2-pdfjs-viewer-container.ng2-has-custom-toolbar iframe{flex:1 1 auto;min-height:0}.ng2-pdfjs-custom-toolbar{flex:0 0 auto}.ng2-pdfjs-viewer-container.ng2-has-custom-sidebar{display:grid;grid-template-columns:auto 1fr;grid-template-rows:auto 1fr;grid-template-areas:\"toolbar toolbar\" \"sidebar viewer\"}.ng2-pdfjs-viewer-container.ng2-has-custom-sidebar .ng2-pdfjs-custom-toolbar{grid-area:toolbar}.ng2-pdfjs-custom-sidebar{grid-area:sidebar;min-height:0;overflow:auto}.ng2-pdfjs-viewer-container.ng2-has-custom-sidebar iframe{grid-area:viewer;min-width:0;min-height:0}.ng2-pdfjs-loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#fff9;-webkit-backdrop-filter:saturate(120%) blur(1px);backdrop-filter:saturate(120%) blur(1px)}.ng2-pdfjs-spinner-content{text-align:center}.ng2-pdfjs-spinner-icon{display:inline-block;width:40px;height:40px;border:4px solid #f3f3f3;border-top:4px solid #2196F3;border-radius:50%;animation:spin 1s linear infinite}.ng2-pdfjs-spinner-text{margin-top:16px;color:#666;font-size:16px}.ng2-pdfjs-error-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#ffffffe6;-webkit-backdrop-filter:saturate(120%) blur(1px);backdrop-filter:saturate(120%) blur(1px)}.ng2-pdfjs-error-content{text-align:center;max-width:400px;padding:20px}.ng2-pdfjs-error-icon{font-size:48px;color:#f44336;margin-bottom:16px}.ng2-pdfjs-error-title{color:#333;font-size:18px;font-weight:500;margin-bottom:8px}.ng2-pdfjs-error-message{color:#666;font-size:14px;line-height:1.4}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.ng2-ai-fab{position:absolute;right:16px;bottom:16px;z-index:20;width:44px;height:44px;border:none;border-radius:50%;cursor:pointer;font-size:18px;line-height:1;color:var(--ng2-ai-fab-color, #fff);background:var(--ng2-ai-accent, #4436a1);box-shadow:0 2px 8px #00000040}.ng2-ai-panel{position:absolute;right:16px;bottom:72px;z-index:20;display:flex;flex-direction:column;width:min(340px,calc(100% - 32px));max-height:min(480px,calc(100% - 96px));border-radius:10px;overflow:hidden;font-size:13px;color:var(--ng2-ai-text, #222);background:var(--ng2-ai-bg, #fff);box-shadow:0 6px 24px #00000047}.ng2-ai-head{display:flex;align-items:center;justify-content:space-between;flex:0 0 auto;padding:10px 12px;font-weight:600;color:#fff;background:var(--ng2-ai-accent, #4436a1)}.ng2-ai-head button{border:none;background:transparent;color:inherit;font-size:18px;line-height:1;cursor:pointer}.ng2-ai-msgs{flex:1 1 auto;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:8px}.ng2-ai-msg{white-space:pre-wrap;word-break:break-word;padding:8px 10px;border-radius:8px;background:var(--ng2-ai-answer-bg, #f2f1f7);align-self:stretch}.ng2-ai-msg.ng2-ai-user{background:var(--ng2-ai-question-bg, #e4f0fe);align-self:flex-end;max-width:85%}.ng2-ai-msg.ng2-ai-busy{opacity:.7;font-style:italic}.ng2-ai-cite{display:inline-block;margin:0 2px;padding:0 6px;border:none;border-radius:9px;cursor:pointer;font:inherit;font-size:12px;color:#fff;background:var(--ng2-ai-accent, #4436a1)}.ng2-ai-error{color:#b3261e}.ng2-ai-input{display:flex;flex:0 0 auto;gap:6px;padding:10px 12px;border-top:1px solid rgba(0,0,0,.08)}.ng2-ai-input input{flex:1 1 auto;min-width:0;padding:6px 8px;border:1px solid rgba(0,0,0,.2);border-radius:6px;font:inherit}.ng2-ai-input button{flex:0 0 auto;padding:6px 12px;border:none;border-radius:6px;cursor:pointer;font:inherit;color:#fff;background:var(--ng2-ai-accent, #4436a1)}.ng2-ai-input button:disabled{opacity:.6;cursor:default}.ng2-pdfjs-viewer-iframe{border:0}.ng2-pdfjs-viewer-iframe.has-border{border:1px solid #ccc}\n"], dependencies: [{ kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: PdfJsViewerComponent, decorators: [{
type: Component,
args: [{ selector: "ng2-pdfjs-viewer", standalone: false, template: `
<div
class="ng2-pdfjs-viewer-container"
[class.ng2-has-custom-toolbar]="customToolbarTpl && !externalWindow"
[class.ng2-has-custom-sidebar]="customSidebarTpl && !externalWindow"
>
<div
class="ng2-pdfjs-custom-toolbar"
*ngIf="customToolbarTpl && !externalWindow"
>
<ng-container
[ngTemplateOutlet]="customToolbarTpl"
[ngTemplateOutletContext]="{ $implicit: this }"
></ng-container>
</div>
<div
class="ng2-pdfjs-custom-sidebar"
*ngIf="customSidebarTpl && !externalWindow"
>
<ng-container
[ngTemplateOutlet]="customSidebarTpl"
[ngTemplateOutletContext]="{ $implicit: this }"
></ng-container>
</div>
<iframe
[title]="iframeTitle || 'PDF document viewer'"
[hidden]="externalWindow || (!externalWindow && !pdfSrc)"
sandbox="allow-forms allow-scripts allow-same-origin allow-modals allow-downloads allow-popups allow-popups-to-escape-sandbox"
[class]="getIframeClasses()"
#iframe
width="100%"
height="100%"
></iframe>
<div
class="ng2-pdfjs-loading-overlay"
*ngIf="showSpinner && isLoading && !externalWindow"
[ngClass]="spinnerClass"
>
<ng-container
*ngIf="customSpinnerTpl; else defaultSpinner"
[ngTemplateOutlet]="customSpinnerTpl"
></ng-container>
<ng-template #defaultSpinner>
<div class="ng2-pdfjs-spinner-content">
<div class="ng2-pdfjs-spinner-icon"></div>
<div class="ng2-pdfjs-spinner-text">
Loading PDF...
</div>
</div>
</ng-template>
</div>
<div
class="ng2-pdfjs-error-overlay"
*ngIf="errorOverride && hasError && !externalWindow"
[ngClass]="errorClass"
>
<ng-container
*ngIf="customErrorTpl; else defaultError"
[ngTemplateOutlet]="customErrorTpl"
[ngTemplateOutletContext]="errorTemplateData"
></ng-container>
<ng-template #defaultError>
<div class="ng2-pdfjs-error-content">
<div class="ng2-pdfjs-error-icon">
⚠️
</div>
<div class="ng2-pdfjs-error-title">
Error Loading PDF
</div>
<div class="ng2-pdfjs-error-message">
{{ currentErrorMessage }}
</div>
</div>
</ng-template>
</div>
<button
type="button"
class="ng2-ai-fab"
*ngIf="aiAssistantConfig && !externalWindow"
(click)="aiPanelOpen = !aiPanelOpen"
[attr.aria-expanded]="aiPanelOpen"
aria-label="Ask AI about this document"
>
✦
</button>
<div
class="ng2-ai-panel"
*ngIf="aiAssistantConfig && aiPanelOpen && !externalWindow"
role="complementary"
aria-label="AI assistant"
>
<div class="ng2-ai-head">
<span>{{ aiAssistantConfig.title || 'Ask this document' }}</span>
<button
type="button"
(click)="aiPanelOpen = false"
aria-label="Close AI panel"
>
×
</button>
</div>
<div class="ng2-ai-msgs" aria-live="polite">
<div
class="ng2-ai-msg"
*ngFor="let m of aiMessages"
[class.ng2-ai-user]="m.role === 'user'"
>
<ng-container *ngFor="let part of m.parts">
<button
type="button"
class="ng2-ai-cite"
*ngIf="part.page; else plainPart"
(click)="setPage(part.page!)"
>
p.{{ part.page }}
</button>
<ng-template #plainPart>{{ part.text }}</ng-template>
</ng-container>
<span class="ng2-ai-error" *ngIf="m.error">{{ m.error }}</span>
</div>
<div class="ng2-ai-msg ng2-ai-busy" *ngIf="aiBusy">Thinking…</div>
</div>
<div class="ng2-ai-input">
<input
#aiq
type="text"
[placeholder]="aiAssistantConfig.placeholder || 'Ask the document…'"
[disabled]="aiBusy"
(keyup.enter)="aiAsk(aiq.value); aiq.value = ''"
aria-label="Question for the AI assistant"
/>
<button
type="button"
[disabled]="aiBusy"
(click)="aiAsk(aiq.value); aiq.value = ''"
>
Ask
</button>
</div>
</div>
<div
class="ng2-pdfjs-error-overlay"
*ngIf="securityWarning && !externalWindow"
[ngClass]="errorClass"
>
<ng-container
*ngIf="customSecurityTpl; else defaultSecurity"
[ngTemplateOutlet]="customSecurityTpl"
[ngTemplateOutletContext]="{ $implicit: securityWarning, securityWarning: securityWarning }"
></ng-container>
<ng-template #defaultSecurity>
<div class="ng2-pdfjs-error-content">
<div class="ng2-pdfjs-error-icon">
⚠️
</div>
<div class="ng2-pdfjs-error-title">
Security Warning
</div>
<div class="ng2-pdfjs-error-message">
{{ securityWarning?.message }}
</div>
</div>
</ng-template>
</div>
</div>
`, styles: [".ng2-pdfjs-viewer-container{position:relative;width:100%;height:100%}.ng2-pdfjs-viewer-container.ng2-has-custom-toolbar{display:flex;flex-direction:column}.ng2-pdfjs-viewer-container.ng2-has-custom-toolbar iframe{flex:1 1 auto;min-height:0}.ng2-pdfjs-custom-toolbar{flex:0 0 auto}.ng2-pdfjs-viewer-container.ng2-has-custom-sidebar{display:grid;grid-template-columns:auto 1fr;grid-template-rows:auto 1fr;grid-template-areas:\"toolbar toolbar\" \"sidebar viewer\"}.ng2-pdfjs-viewer-container.ng2-has-custom-sidebar .ng2-pdfjs-custom-toolbar{grid-area:toolbar}.ng2-pdfjs-custom-sidebar{grid-area:sidebar;min-height:0;overflow:auto}.ng2-pdfjs-viewer-container.ng2-has-custom-sidebar iframe{grid-area:viewer;min-width:0;min-height:0}.ng2-pdfjs-loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#fff9;-webkit-backdrop-filter:saturate(120%) blur(1px);backdrop-filter:saturate(120%) blur(1px)}.ng2-pdfjs-spinner-content{text-align:center}.ng2-pdfjs-spinner-icon{display:inline-block;width:40px;height:40px;border:4px solid #f3f3f3;border-top:4px solid #2196F3;border-radius:50%;animation:spin 1s linear infinite}.ng2-pdfjs-spinner-text{margin-top:16px;color:#666;font-size:16px}.ng2-pdfjs-error-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#ffffffe6;-webkit-backdrop-filter:saturate(120%) blur(1px);backdrop-filter:saturate(120%) blur(1px)}.ng2-pdfjs-error-content{text-align:center;max-width:400px;padding:20px}.ng2-pdfjs-error-icon{font-size:48px;color:#f44336;margin-bottom:16px}.ng2-pdfjs-error-title{color:#333;font-size:18px;font-weight:500;margin-bottom:8px}.ng2-pdfjs-error-message{color:#666;font-size:14px;line-height:1.4}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.ng2-ai-fab{position:absolute;right:16px;bottom:16px;z-index:20;width:44px;height:44px;border:none;border-radius:50%;cursor:pointer;font-size:18px;line-height:1;color:var(--ng2-ai-fab-color, #fff);background:var(--ng2-ai-accent, #4436a1);box-shadow:0 2px 8px #00000040}.ng2-ai-panel{position:absolute;right:16px;bottom:72px;z-index:20;display:flex;flex-direction:column;width:min(340px,calc(100% - 32px));max-height:min(480px,calc(100% - 96px));border-radius:10px;overflow:hidden;font-size:13px;color:var(--ng2-ai-text, #222);background:var(--ng2-ai-bg, #fff);box-shadow:0 6px 24px #00000047}.ng2-ai-head{display:flex;align-items:center;justify-content:space-between;flex:0 0 auto;padding:10px 12px;font-weight:600;color:#fff;background:var(--ng2-ai-accent, #4436a1)}.ng2-ai-head button{border:none;background:transparent;color:inherit;font-size:18px;line-height:1;cursor:pointer}.ng2-ai-msgs{flex:1 1 auto;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:8px}.ng2-ai-msg{white-space:pre-wrap;word-break:break-word;padding:8px 10px;border-radius:8px;background:var(--ng2-ai-answer-bg, #f2f1f7);align-self:stretch}.ng2-ai-msg.ng2-ai-user{background:var(--ng2-ai-question-bg, #e4f0fe);align-self:flex-end;max-width:85%}.ng2-ai-msg.ng2-ai-busy{opacity:.7;font-style:italic}.ng2-ai-cite{display:inline-block;margin:0 2px;padding:0 6px;border:none;border-radius:9px;cursor:pointer;font:inherit;font-size:12px;color:#fff;background:var(--ng2-ai-accent, #4436a1)}.ng2-ai-error{color:#b3261e}.ng2-ai-input{display:flex;flex:0 0 auto;gap:6px;padding:10px 12px;border-top:1px solid rgba(0,0,0,.08)}.ng2-ai-input input{flex:1 1 auto;min-width:0;padding:6px 8px;border:1px solid rgba(0,0,0,.2);border-radius:6px;font:inherit}.ng2-ai-input button{flex:0 0 auto;padding:6px 12px;border:none;border-radius:6px;cursor:pointer;font:inherit;color:#fff;background:var(--ng2-ai-accent, #4436a1)}.ng2-ai-input button:disabled{opacity:.6;cursor:default}.ng2-pdfjs-viewer-iframe{border:0}.ng2-pdfjs-viewer-iframe.has-border{border:1px solid #ccc}\n"] }]
}], ctorParameters: () => [{ type: i0.ChangeDetectorRef }, { type: i0.ApplicationRef }, { type: i0.NgZone }], propDecorators: { iframe: [{
type: ViewChild,
args: ["iframe", { static: true }]
}], viewerId: [{
type: Input
}], onBeforePrint: [{
type: Output
}], onAfterPrint: [{
type: Output
}], onDocumentLoad: [{
type: Output
}], onPageChange: [{
type: Output
}], onScaleChange: [{
type: Output
}], onRotationChange: [{
type: Output
}], onDocumentError: [{
type: Output
}], onDocumentInit: [{
type: Output
}], onPagesInit: [{
type: Output
}], onPresentationModeChanged: [{
type: Output
}], onOpenFile: [{
type: Output
}], onFind: [{
type: Output
}], onUpdateFindMatchesCount: [{
type: Output
}], onMetadataLoaded: [{
type: Output
}], onOutlineLoaded: [{
type: Output
}], onPageRendered: [{
type: Output
}], onAnnotationLayerRendered: [{
type: Output
}], onBookmarkClick: [{
type: Output
}], onIdle: [{
type: Output
}], onPasswordPrompt: [{
type: Output
}], onAnnotationEditorStateChange: [{
type: Output
}], onPagesEdited: [{
type: Output
}], onReadAloudStateChange: [{
type: Output
}], onSidebarViewChanged: [{
type: Output
}], onLayersChanged: [{
type: Output
}], onNamedAction: [{
type: Output
}], onDocumentProperties: [{
type: Output
}], viewerFolder: [{
type: Input
}], externalWindow: [{
type: Input
}], target: [{
type: Input
}], showSpinner: [{
type: Input
}], downloadFileName: [{
type: Input
}], locale: [{
type: Input
}], useOnlyCssZoom: [{
type: Input
}], externalLinkTarget: [{
type: Input
}], rememberLastView: [{
type: Input
}], annotationEditor: [{
type: Input
}], annotationEditorChange: [{
type: Output
}], highlightEditorColors: [{
type: Input
}], enableSignatureEditor: [{
type: Input
}], signatureStorage: [{
type: Input
}], pageColors: [{
type: Input
}], pdfJsOptions: [{
type: Input
}], enableCommentEditor: [{
type: Input
}], enablePageEditing: [{
type: Input
}], showToolbar: [{
type: Input
}], chromeless: [{
type: Input
}], customToolbarTpl: [{
type: Input
}], customSidebarTpl: [{
type: Input
}], aiAssistantConfig: [{
type: Input
}], pageOverlayTpl: [{
type: Input
}], httpHeaders: [{
type: Input
}], withCredentials: [{
type: Input
}], onProgress: [{
type: Output
}], formData: [{
type: Input
}], formDataChange: [{
type: Output
}], contentProtection: [{
type: Input
}], iframeSandbox: [{
type: Input
}], diagnosticLogs: [{
type: Input
}], showOpenFile: [{
type: Input
}], showAnnotations: [{
type: Input
}], showDownload: [{
type: Input
}], showViewBookmark: [{
type: Input
}], showPrint: [{
type: Input
}], showFullScreen: [{
type: Input
}], showFind: [{
type: Input
}], downloadOnLoad: [{
type: Input
}], printOnLoad: [{
type: Input
}], rotateCW: [{
type: Input
}], rotateCCW: [{
type: Input
}], showLastPageOnLoad: [{
type: Input
}], namedDest: [{
type: Input
}], errorOverride: [{
type: Input
}], errorAppend: [{
type: Input
}], errorMessage: [{
type: Input
}], urlValidation: [{
type: Input
}], customSecurityTpl: [{
type: Input
}], theme: [{
type: Input
}], primaryColor: [{
type: Input
}], backgroundColor: [{
type: Input
}], pageBorderColor: [{
type: Input
}], pageSpacing: [{
type: Input
}], toolbarColor: [{
type: Input
}], textColor: [{
type: Input
}], borderRadius: [{
type: Input
}], customCSS: [{
type: Input
}], cspNonce: [{
type: Input
}], iframeTitle: [{
type: Input
}], customSpinnerTpl: [{
type: Input
}], spinnerClass: [{
type: Input
}], customErrorTpl: [{
type: Input
}], errorClass: [{
type: Input
}], showToolbarLeft: [{
type: Input
}], showToolbarMiddle: [{
type: Input
}], showToolbarRight: [{
type: Input
}], showSecondaryToolbarToggle: [{
type: Input
}], showSidebar: [{
type: Input
}], showSidebarLeft: [{
type: Input
}], showSidebarRight: [{
type: Input
}], toolbarDensity: [{
type: Input
}], sidebarWidth: [{
type: Input
}], toolbarPosition: [{
type: Input
}], sidebarPosition: [{
type: Input
}], responsiveBreakpoint: [{
type: Input
}], controlVisibility: [{
type: Input
}], autoActions: [{
type: Input
}], errorHandling: [{
type: Input
}], viewerConfig: [{
type: Input
}], themeConfig: [{
type: Input
}], groupVisibility: [{
type: Input
}], layoutConfig: [{
type: Input
}], startDownload: [{
type: Input
}], startPrint: [{
type: Input
}], openFile: [{
type: Input
}], download: [{
type: Input
}], print: [{
type: Input
}], fullScreen: [{
type: Input
}], find: [{
type: Input
}], viewBookmark: [{
type: Input
}], lastPage: [{
type: Input
}], externalWindowOptions: [{
type: Input
}], iframeBorder: [{
type: Input
}], zoomChange: [{
type: Output
}], cursorChange: [{
type: Output
}], scrollChange: [{
type: Output
}], spreadChange: [{
type: Output
}], pageModeChange: [{
type: Output
}], zoom: [{
type: Input
}], rotation: [{
type: Input
}], cursor: [{
type: Input
}], scroll: [{
type: Input
}], spread: [{
type: Input
}], pageMode: [{
type: Input
}], page: [{
type: Input
}], pdfSrc: [{
type: Input
}] } });
class PdfJsViewerModule {
/** @deprecated Import PdfJsViewerModule directly; forRoot() registers no providers. */
static forRoot() {
return {
ngModule: PdfJsViewerModule,
};
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: PdfJsViewerModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: PdfJsViewerModule, declarations: [PdfJsViewerComponent], imports: [CommonModule], exports: [PdfJsViewerComponent] });
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: PdfJsViewerModule, imports: [CommonModule] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: PdfJsViewerModule, decorators: [{
type: NgModule,
args: [{
imports: [CommonModule],
declarations: [PdfJsViewerComponent],
exports: [PdfJsViewerComponent],
}]
}] });
/**
* Generated bundle index. Do not edit.
*/
export { PdfJsViewerComponent, PdfJsViewerModule };