AAE-45391 Lazy loading and improvements for pdfJS (#11869)

* [AAE-45391] - Lazy loading some of the big external libs

* [AAE-45391] - Added some extra units to pdfjs viewer

* [AAE-45391] - Some improement on viewer component

* [AAE-45391] - Fixed the way it was redered to a newer angular system

* [AAE-45391] - Fixed the way it was redered to a newer angular system

* [AAE-45391] - fixed schematic

* [AAE-45391] - Added some docs for the schematics as well

* [AAE-37328] - Fixed comment on missing provider
This commit is contained in:
Vito Albano
2026-05-11 14:17:39 +01:00
committed by GitHub
parent 62fb51e910
commit 9fe4006a0d
42 changed files with 939 additions and 65 deletions
+77
View File
@@ -0,0 +1,77 @@
# @alfresco/adf-core/viewer/pdf
Secondary entrypoint that provides the PDF viewer component, extracted from `@alfresco/adf-core` to make `pdfjs-dist` an optional dependency.
## Setup
1. Install `pdfjs-dist` (if not already present):
```bash
npm install pdfjs-dist
```
2. Add `providePdfViewer()` to your app providers:
```typescript
import { providePdfViewer } from '@alfresco/adf-core/viewer/pdf';
// Standalone app (app.config.ts)
export const appConfig: ApplicationConfig = {
providers: [
providePdfViewer(),
// ...other providers
]
};
// NgModule-based app (app.module.ts)
@NgModule({
providers: [providePdfViewer()]
})
export class AppModule {}
```
That's it. The viewer will render PDFs using the real `PdfViewerComponent`.
## Migration from v8.x
If upgrading from a version where `PdfViewerComponent` was part of `@alfresco/adf-core` directly, run:
```bash
ng update @alfresco/adf-core
```
The migration schematic will:
- Rewrite imports of PDF symbols (`PdfViewerComponent`, `PdfPasswordDialogComponent`, `PdfThumbListComponent`, `PdfThumbComponent`, `PDFJS_MODULE`, `PDFJS_VIEWER_MODULE`, `RenderingQueueServices`) from `@alfresco/adf-core` to `@alfresco/adf-core/viewer/pdf`
- Add `providePdfViewer()` to your app's providers array
## Opting out of PDF support
If your application does not need to render PDFs, you can skip installing `pdfjs-dist` and omit `providePdfViewer()`. The viewer will display an "unknown format" placeholder for PDF files.
## Exports
| Symbol | Description |
|--------|-------------|
| `providePdfViewer()` | Provider function that registers the PDF viewer |
| `PdfViewerComponent` | The PDF viewer component |
| `PdfPasswordDialogComponent` | Password dialog for protected PDFs |
| `PdfThumbListComponent` | PDF thumbnail list |
| `PdfThumbComponent` | Individual PDF thumbnail |
| `PDFJS_MODULE` | Injection token for pdfjs-dist library |
| `PDFJS_VIEWER_MODULE` | Injection token for pdfjs viewer module |
| `RenderingQueueServices` | Service managing PDF page render queue |
## Architecture
```
@alfresco/adf-core (primary entrypoint)
- Exports: PDF_VIEWER_COMPONENT token, PdfViewerRef interface
- ViewerRenderComponent injects the token via NgComponentOutlet
- Does NOT depend on pdfjs-dist
@alfresco/adf-core/viewer/pdf (this entrypoint)
- Imports from: @alfresco/adf-core, pdfjs-dist
- Exports: PdfViewerComponent, providePdfViewer(), etc.
```
The dependency is one-way (secondary -> primary). The primary entrypoint never imports from this package, avoiding circular dependencies and keeping `pdfjs-dist` out of the main bundle.
+8
View File
@@ -0,0 +1,8 @@
{
"lib": {
"entryFile": "src/index.ts",
"styleIncludePaths": [
"../../src/lib"
]
}
}
+23
View File
@@ -0,0 +1,23 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './lib/components/pdf-viewer/pdf-viewer.component';
export * from './lib/components/pdf-viewer-thumbnails/pdf-viewer-thumbnails.component';
export * from './lib/components/pdf-viewer-thumb/pdf-viewer-thumb.component';
export * from './lib/components/pdf-viewer-password-dialog/pdf-viewer-password-dialog';
export * from './lib/services/rendering-queue.services';
export * from './lib/provide-pdf-viewer';
@@ -0,0 +1,53 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const annotations = [
{
subtype: 'Text',
name: 'NoIcon',
id: 'R13',
titleObj: {
str: 'Annotation title'
},
contentsObj: {
str: 'Annotation contents'
},
modificationDate: "D:20260202104106Z00'00",
popupRef: 'R1'
}
];
export default {
GlobalWorkerOptions: {},
getDocument: jasmine.createSpy('getDocument').and.callFake(() => ({
loadingTask: () => ({
destroy: () => Promise.resolve()
}),
promise: new Promise((resolve) => {
resolve({
numPages: 6,
getPage: () =>
Promise.resolve({
getAnnotations: () => annotations
})
});
})
})),
PasswordResponses: {
NEED_PASSWORD: 1,
INCORRECT_PASSWORD: 2
}
};
@@ -0,0 +1,31 @@
<div mat-dialog-title>
<mat-icon adf-icon="lock" />
</div>
<mat-dialog-content>
<form (submit)="submit()">
<mat-form-field class="adf-full-width">
<input matInput
data-automation-id='adf-password-dialog-input'
type="password"
placeholder="{{ 'ADF_VIEWER.PDF_DIALOG.PLACEHOLDER' | translate }}"
[formControl]="passwordFormControl" />
</mat-form-field>
<mat-error *ngIf="isError()" data-automation-id='adf-password-dialog-error'>{{ 'ADF_VIEWER.PDF_DIALOG.ERROR' | translate }}</mat-error>
</form>
</mat-dialog-content>
<mat-dialog-actions class="adf-dialog-buttons">
<span class="adf-fill-remaining-space"></span>
<button mat-button mat-dialog-close data-automation-id='adf-password-dialog-close'>{{ 'ADF_VIEWER.PDF_DIALOG.CLOSE' | translate }}</button>
<button mat-button
data-automation-id='adf-password-dialog-submit'
class="adf-dialog-action-button"
[disabled]="!isValid()"
(click)="submit()">
{{ 'ADF_VIEWER.PDF_DIALOG.SUBMIT' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,11 @@
.adf-fill-remaining-space {
flex: 1 1 auto;
}
.adf-full-width {
width: 100%;
}
.adf-dialog-buttons button {
text-transform: uppercase;
}
@@ -0,0 +1,102 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { PdfPasswordDialogComponent } from './pdf-viewer-password-dialog';
declare const pdfjsLib: { PasswordResponses: { NEED_PASSWORD: number; INCORRECT_PASSWORD: number } };
describe('PdfPasswordDialogComponent', () => {
let component: PdfPasswordDialogComponent;
let fixture: ComponentFixture<PdfPasswordDialogComponent>;
let dialogRef: MatDialogRef<PdfPasswordDialogComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [PdfPasswordDialogComponent],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
reason: null
}
},
{
provide: MatDialogRef,
useValue: {
close: jasmine.createSpy('open')
}
}
]
});
fixture = TestBed.createComponent(PdfPasswordDialogComponent);
component = fixture.componentInstance;
dialogRef = TestBed.inject(MatDialogRef);
});
it('should have empty default value', () => {
fixture.detectChanges();
expect(component.passwordFormControl.value).toBe('');
});
describe('isError', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should return false', () => {
component.data.reason = pdfjsLib.PasswordResponses.NEED_PASSWORD;
expect(component.isError()).toBe(false);
});
it('should return true', () => {
component.data.reason = pdfjsLib.PasswordResponses.INCORRECT_PASSWORD;
expect(component.isError()).toBe(true);
});
});
describe('isValid', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should return false when input has no value', () => {
component.passwordFormControl.setValue('');
expect(component.isValid()).toBe(false);
});
it('should return true when input has a valid value', () => {
component.passwordFormControl.setValue('some-text');
expect(component.isValid()).toBe(true);
});
});
it('should close dialog with input value', () => {
fixture.detectChanges();
component.passwordFormControl.setValue('some-value');
component.submit();
expect(dialogRef.close).toHaveBeenCalledWith('some-value');
});
});
@@ -0,0 +1,58 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgIf } from '@angular/common';
import { Component, OnInit, ViewEncapsulation, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { ReactiveFormsModule, UntypedFormControl, Validators } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { IconModule } from '@alfresco/adf-core';
declare const pdfjsLib: { PasswordResponses: { NEED_PASSWORD: number; INCORRECT_PASSWORD: number } };
@Component({
selector: 'adf-pdf-viewer-password-dialog',
templateUrl: './pdf-viewer-password-dialog.html',
styleUrls: ['./pdf-viewer-password-dialog.scss'],
imports: [MatDialogModule, IconModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule, TranslatePipe, NgIf, MatButtonModule],
encapsulation: ViewEncapsulation.None
})
export class PdfPasswordDialogComponent implements OnInit {
private readonly dialogRef = inject<MatDialogRef<PdfPasswordDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
passwordFormControl: UntypedFormControl;
ngOnInit() {
this.passwordFormControl = new UntypedFormControl('', [Validators.required]);
}
isError(): boolean {
return this.data.reason === pdfjsLib.PasswordResponses.INCORRECT_PASSWORD;
}
isValid(): boolean {
return !this.passwordFormControl.hasError('required');
}
submit(): void {
this.dialogRef.close(this.passwordFormControl.value);
}
}
@@ -0,0 +1,6 @@
<ng-container *ngIf="image$ | async as image">
<img [src]="image" role="button"
[alt]="'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id }"
title="{{ 'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id } }}"
[attr.aria-label]="'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id }">
</ng-container>
@@ -0,0 +1,73 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DomSanitizer } from '@angular/platform-browser';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PdfThumbComponent } from './pdf-viewer-thumb.component';
describe('PdfThumbComponent', () => {
let fixture: ComponentFixture<PdfThumbComponent>;
let component: PdfThumbComponent;
const domSanitizer = {
bypassSecurityTrustUrl: () => 'image-data'
};
const width = 91;
const height = 119;
const page = {
id: 1,
getPage: jasmine.createSpy('getPage').and.returnValue(
Promise.resolve({
getViewport: () => ({ width, height }),
render: jasmine.createSpy('render').and.returnValue({ promise: Promise.resolve() })
})
),
getWidth: jasmine.createSpy('getWidth').and.returnValue(width),
getHeight: jasmine.createSpy('getHeight').and.returnValue(height)
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [PdfThumbComponent],
providers: [
{
provide: DomSanitizer,
useValue: domSanitizer
}
]
});
fixture = TestBed.createComponent(PdfThumbComponent);
component = fixture.componentInstance;
});
it('should have resolve image data', (done) => {
component.page = page;
fixture.detectChanges();
component.image$.then((result) => {
expect(result).toBe('image-data');
done();
});
});
it('should focus element', () => {
component.page = page;
fixture.detectChanges();
component.focus();
expect(fixture.debugElement.nativeElement.id).toBe(document.activeElement.id);
});
});
@@ -0,0 +1,71 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FocusableOption } from '@angular/cdk/a11y';
import { AsyncPipe, NgIf } from '@angular/common';
import { Component, ElementRef, Input, OnInit, ViewEncapsulation, inject } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'adf-pdf-thumb',
templateUrl: './pdf-viewer-thumb.component.html',
encapsulation: ViewEncapsulation.None,
imports: [AsyncPipe, TranslatePipe, NgIf],
host: { tabindex: '0' }
})
export class PdfThumbComponent implements OnInit, FocusableOption {
private readonly sanitizer = inject(DomSanitizer);
private readonly element = inject(ElementRef);
@Input()
page: any;
image$: Promise<string>;
ngOnInit() {
this.image$ = this.page.getPage().then((page) => this.getThumb(page));
}
focus() {
this.element.nativeElement.focus();
}
private getThumb(page): Promise<string> {
const viewport = page.getViewport({ scale: 1 });
const canvas = this.getCanvas();
const scale = Math.min(canvas.height / viewport.height, canvas.width / viewport.width);
return page
.render({
canvasContext: canvas.getContext('2d'),
viewport: page.getViewport({ scale })
})
.promise.then(() => {
const imageSource = canvas.toDataURL();
return this.sanitizer.bypassSecurityTrustUrl(imageSource);
});
}
private getCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = this.page.getWidth();
canvas.height = this.page.getHeight();
return canvas;
}
}
@@ -0,0 +1,13 @@
<div class="adf-pdf-thumbnails__content"
data-automation-id='adf-thumbnails-content'
[style.height.px]="virtualHeight"
[style.transform]="'translate(-50%, ' + translateY + 'px)'">
@for (page of renderItems; track page.id) {
<adf-pdf-thumb
class="adf-pdf-thumbnails__thumb"
[id]="page.id"
[ngClass]="{'adf-pdf-thumbnails__thumb--selected' : isSelected(page.id)}"
[page]="page"
(click)="goTo(page.id)" />
}
</div>
@@ -0,0 +1,30 @@
.adf-pdf-thumbnails {
display: block;
overflow: hidden;
overflow-y: auto;
height: 100%;
position: relative;
&__content {
top: 5px;
left: 50%;
height: 0;
position: absolute;
}
&__thumb {
cursor: pointer;
display: block;
width: 91px;
background: var(--mat-sys-surface);
margin-bottom: 15px;
}
&__thumb:hover {
box-shadow: var(--mat-sys-level2);
}
&__thumb--selected {
border: 2px solid var(--mat-sys-primary);
}
}
@@ -0,0 +1,236 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PdfThumbListComponent } from './pdf-viewer-thumbnails.component';
import { UnitTestingUtils } from '@alfresco/adf-core';
import { DOWN_ARROW, ESCAPE, UP_ARROW } from '@angular/cdk/keycodes';
declare const pdfjsViewer: any;
describe('PdfThumbListComponent', () => {
let fixture: ComponentFixture<PdfThumbListComponent>;
let component: PdfThumbListComponent;
let testingUtils: UnitTestingUtils;
const page = (id) => ({
id,
getPage: Promise.resolve()
});
const viewerMock = {
_currentPageNumber: null,
set currentPageNumber(pageNum) {
this._currentPageNumber = pageNum;
/* cspell:disable-next-line */
this.eventBus.dispatch('pagechanging', { pageNumber: pageNum });
},
get currentPageNumber() {
return this._currentPageNumber;
},
pdfDocument: {
getPage: () =>
Promise.resolve({
getViewport: () => ({ height: 421, width: 335 }),
render: jasmine.createSpy('render').and.returnValue({ promise: Promise.resolve() })
})
},
_pages: [
page(1),
page(2),
page(3),
page(4),
page(5),
page(6),
page(7),
page(8),
page(9),
page(10),
page(11),
page(12),
page(13),
page(14),
page(15),
page(16)
],
eventBus: new pdfjsViewer.EventBus()
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [PdfThumbListComponent]
});
fixture = TestBed.createComponent(PdfThumbListComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
component = fixture.componentInstance;
component.pdfViewer = viewerMock as any;
// provide scrollable container
fixture.nativeElement.style.display = 'block';
fixture.nativeElement.style.height = '700px';
fixture.nativeElement.style.overflow = 'scroll';
const content = testingUtils.getByCSS('.adf-pdf-thumbnails__content').nativeElement;
content.style.height = '2000px';
content.style.position = 'unset';
});
it('should render initial rage of items', () => {
fixture.nativeElement.scrollTop = 0;
fixture.detectChanges();
const renderedIds = component.renderItems.map((item) => item.id);
// eslint-disable-next-line no-underscore-dangle
const rangeIds = viewerMock._pages.slice(0, 6).map((item) => item.id);
expect(renderedIds).toEqual(rangeIds);
});
it('should render next range on scroll', () => {
component.currentHeight = 114;
fixture.nativeElement.scrollTop = 700;
fixture.detectChanges();
const renderedIds = component.renderItems.map((item) => item.id);
// eslint-disable-next-line no-underscore-dangle
const rangeIds = viewerMock._pages.slice(5, 12).map((item) => item.id);
expect(renderedIds).toEqual(rangeIds);
});
it('should render items containing current document page', () => {
fixture.detectChanges();
const renderedIds = component.renderItems.map((item) => item.id);
expect(renderedIds).not.toContain(10);
component.scrollInto(10);
const newRenderedIds = component.renderItems.map((item) => item.id);
expect(newRenderedIds).toContain(10);
});
it('should not change items if range contains current document page', () => {
fixture.nativeElement.scrollTop = 1700;
fixture.detectChanges();
const renderedIds = component.renderItems.map((item) => item.id);
expect(renderedIds).toContain(12);
/* cspell:disable-next-line */
viewerMock.eventBus.dispatch('pagechanging', { pageNumber: 12 });
const newRenderedIds = component.renderItems.map((item) => item.id);
expect(newRenderedIds).toContain(12);
});
it('should scroll thumbnail height amount to buffer thumbnail onPageChange event', () => {
spyOn(component, 'scrollInto');
fixture.detectChanges();
expect(component.renderItems[component.renderItems.length - 1].id).toBe(6);
expect(fixture.debugElement.nativeElement.scrollTop).toBe(0);
component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 6 });
expect(component.scrollInto).not.toHaveBeenCalled();
expect(fixture.debugElement.nativeElement.scrollTop).toBe(0);
});
it('should set active current page on onPageChange event', () => {
fixture.detectChanges();
component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 6 });
expect(document.activeElement.id).toBe('6');
});
it('should return current viewed page as selected', () => {
fixture.nativeElement.scrollTop = 0;
fixture.detectChanges();
viewerMock.currentPageNumber = 2;
expect(component.isSelected(2)).toBe(true);
});
it('should go to selected page', () => {
fixture.detectChanges();
component.goTo(12);
expect(viewerMock.currentPageNumber).toBe(12);
});
describe('Keyboard events', () => {
it('should select next page in the list on DOWN_ARROW event', () => {
const event = new KeyboardEvent('keydown', { keyCode: DOWN_ARROW } as KeyboardEventInit);
fixture.detectChanges();
component.goTo(1);
expect(document.activeElement.id).toBe('1');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('2');
});
it('should select previous page in the list on UP_ARROW event', () => {
const event = new KeyboardEvent('keydown', { keyCode: UP_ARROW } as KeyboardEventInit);
fixture.detectChanges();
component.goTo(2);
expect(document.activeElement.id).toBe('2');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('1');
});
it('should not select previous page if it is the first page', () => {
const event = new KeyboardEvent('keydown', { keyCode: UP_ARROW } as KeyboardEventInit);
fixture.detectChanges();
component.goTo(1);
expect(document.activeElement.id).toBe('1');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('1');
});
it('should not select next item if it is the last page', () => {
const event = new KeyboardEvent('keydown', { keyCode: DOWN_ARROW } as KeyboardEventInit);
fixture.detectChanges();
component.scrollInto(16);
fixture.detectChanges();
component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 16 });
expect(document.activeElement.id).toBe('16');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('16');
});
it('should emit on ESCAPE event', () => {
const event = new KeyboardEvent('keydown', { keyCode: ESCAPE } as KeyboardEventInit);
spyOn(component.close, 'emit');
fixture.detectChanges();
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(component.close.emit).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,246 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FocusKeyManager } from '@angular/cdk/a11y';
import { DOWN_ARROW, ESCAPE, TAB, UP_ARROW } from '@angular/cdk/keycodes';
import { DOCUMENT, NgClass } from '@angular/common';
import {
AfterViewInit,
Component,
ContentChild,
ElementRef,
EventEmitter,
HostListener,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
TemplateRef,
ViewChildren,
ViewEncapsulation,
inject
} from '@angular/core';
import { delay } from 'rxjs/operators';
import { PdfThumbComponent } from '../pdf-viewer-thumb/pdf-viewer-thumb.component';
@Component({
selector: 'adf-pdf-thumbnails',
templateUrl: './pdf-viewer-thumbnails.component.html',
styleUrls: ['./pdf-viewer-thumbnails.component.scss'],
host: { class: 'adf-pdf-thumbnails' },
imports: [PdfThumbComponent, NgClass],
encapsulation: ViewEncapsulation.None
})
export class PdfThumbListComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly element = inject(ElementRef);
private readonly document = inject(DOCUMENT);
@Input({ required: true }) pdfViewer: any;
@Output()
close = new EventEmitter<void>();
virtualHeight: number = 0;
translateY: number = 0;
renderItems: any[] = [];
width: number = 91;
currentHeight: number = 0;
private items: any[] = [];
private readonly margin: number = 15;
private itemHeight: number = 114 + this.margin;
private previouslyFocusedElement: HTMLElement | null = null;
private keyManager: FocusKeyManager<PdfThumbComponent>;
@ContentChild(TemplateRef)
template: TemplateRef<unknown>;
@ViewChildren(PdfThumbComponent)
thumbsList: QueryList<PdfThumbComponent>;
@HostListener('keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
const keyCode = event.keyCode;
if (keyCode === UP_ARROW && this.canSelectPreviousItem()) {
this.pdfViewer.currentPageNumber -= 1;
}
if (keyCode === DOWN_ARROW && this.canSelectNextItem()) {
this.pdfViewer.currentPageNumber += 1;
}
if (keyCode === TAB) {
if (this.canSelectNextItem()) {
this.pdfViewer.currentPageNumber += 1;
} else {
this.close.emit();
}
}
if (keyCode === ESCAPE) {
this.close.emit();
}
this.keyManager.setFocusOrigin('keyboard');
event.preventDefault();
}
@HostListener('window:resize')
onResize() {
this.calculateItems();
}
constructor() {
this.calculateItems = this.calculateItems.bind(this);
this.onPageChange = this.onPageChange.bind(this);
}
ngOnInit() {
/* cspell:disable-next-line */
this.pdfViewer.eventBus.on('pagechanging', this.onPageChange);
this.element.nativeElement.addEventListener('scroll', this.calculateItems, true);
this.setHeight(this.pdfViewer.currentPageNumber);
this.items = this.getPages();
this.calculateItems();
this.previouslyFocusedElement = this.document.activeElement as HTMLElement;
}
ngAfterViewInit() {
this.keyManager = new FocusKeyManager(this.thumbsList);
this.thumbsList.changes.pipe(delay(0)).subscribe(() => this.keyManager.setActiveItem(this.getPageIndex(this.pdfViewer.currentPageNumber)));
setTimeout(() => {
this.scrollInto(this.pdfViewer.currentPageNumber);
this.keyManager.setActiveItem(this.getPageIndex(this.pdfViewer.currentPageNumber));
}, 0);
}
ngOnDestroy() {
this.element.nativeElement.removeEventListener('scroll', this.calculateItems, true);
/* cspell:disable-next-line */
this.pdfViewer.eventBus.on('pagechanging', this.onPageChange);
if (this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus();
this.previouslyFocusedElement = null;
}
}
isSelected(pageNumber: number) {
return this.pdfViewer.currentPageNumber === pageNumber;
}
goTo(pageNumber: number) {
this.pdfViewer.currentPageNumber = pageNumber;
}
scrollInto(pageNumber: number) {
if (this.items.length) {
const index: number = this.items.findIndex((element) => element.id === pageNumber);
if (index < 0 || index >= this.items.length) {
return;
}
this.element.nativeElement.scrollTop = index * this.itemHeight;
this.calculateItems();
}
}
getPages(): any[] {
// eslint-disable-next-line no-underscore-dangle
return this.pdfViewer._pages.map((page) => ({
id: page.id,
getWidth: () => this.width,
getHeight: () => this.currentHeight,
getPage: () => this.pdfViewer.pdfDocument.getPage(page.id)
}));
}
private setHeight(id: number): Promise<void> {
return this.pdfViewer.pdfDocument.getPage(id).then((page) => this.calculateHeight(page));
}
private calculateHeight(page) {
const viewport = page.getViewport({ scale: 1 });
const pageRatio = viewport.width / viewport.height;
const height = Math.floor(this.width / pageRatio);
this.currentHeight = height;
this.itemHeight = height + this.margin;
}
private calculateItems() {
const { element, viewPort, itemsInView } = this.getContainerSetup();
const indexByScrollTop = ((element.scrollTop / viewPort) * this.items.length) / itemsInView;
const start = Math.floor(indexByScrollTop);
const end = Math.ceil(indexByScrollTop) + itemsInView;
this.translateY = this.itemHeight * Math.ceil(start);
this.virtualHeight = this.itemHeight * this.items.length - this.translateY;
this.renderItems = this.items.slice(start, end);
}
private getContainerSetup() {
const element = this.element.nativeElement;
const elementRec = element.getBoundingClientRect();
const itemsInView = Math.ceil(elementRec.height / this.itemHeight);
const viewPort = (this.itemHeight * this.items.length) / itemsInView;
return {
element,
viewPort,
itemsInView
};
}
private onPageChange(event: any) {
const index = this.renderItems.findIndex((element) => element.id === event.pageNumber);
if (index < 0) {
this.scrollInto(event.pageNumber);
}
if (index >= this.renderItems.length - 1) {
this.element.nativeElement.scrollTop += this.itemHeight;
}
this.keyManager.setActiveItem(this.getPageIndex(event.pageNumber));
}
private getPageIndex(pageNumber: number): number {
const thumbsListArray = this.thumbsList.toArray();
return thumbsListArray.findIndex((el) => el.page.id === pageNumber);
}
private canSelectNextItem(): boolean {
return this.pdfViewer.currentPageNumber !== this.pdfViewer.pagesCount;
}
private canSelectPreviousItem(): boolean {
return this.pdfViewer.currentPageNumber !== 1;
}
}
@@ -0,0 +1,236 @@
/* stylelint-disable selector-class-pattern */
.adf-pdf-viewer {
.textLayer {
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0.2;
line-height: 1;
border: 1px solid gray;
& > div {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0 0;
}
.adf-highlight {
margin: -1px;
padding: 1px;
background-color: rgb(180, 0, 170);
border-radius: 4px;
&.adf-begin {
border-radius: 4px 0 0 4px;
}
&.adf-end {
border-radius: 0 4px 4px 0;
}
&.adf-middle {
border-radius: 0;
}
&.adf-selected {
background-color: rgb(0, 100, 0);
}
}
&::selection {
background: rgb(0, 0, 255);
}
.adf-endOfContent {
display: block;
position: absolute;
inset: 0;
top: 100%;
z-index: -1;
cursor: default;
user-select: none;
&.adf-active {
top: 0;
}
}
}
.adf-annotationLayer {
section {
position: absolute;
}
.adf-linkAnnotation {
& > a {
position: absolute;
font-size: 1em;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* stylelint-disable */
background: url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7') 0 0 repeat;
/* stylelint-enable */
&:hover {
opacity: 0.2;
background: #ff0;
box-shadow: 0 2px 10px #ff0;
}
}
}
.adf-textAnnotation {
img {
position: absolute;
cursor: pointer;
}
}
.adf-popupWrapper {
position: absolute;
width: 20em;
}
.adf-popup {
position: absolute;
z-index: 200;
max-width: 20em;
background-color: #ff9;
box-shadow: 0 2px 5px #333;
border-radius: 2px;
padding: 0.6em;
margin-left: 5px;
cursor: pointer;
word-wrap: break-word;
h1 {
font-size: 1em;
border-bottom: 1px solid #000;
padding-bottom: 0.2em;
}
p {
padding-top: 0.2em;
}
}
.adf-highlightAnnotation,
.adf-underlineAnnotation,
.adf-squigglyAnnotation,
.adf-strikeoutAnnotation,
.adf-fileAttachmentAnnotation {
cursor: pointer;
}
}
.adf-pdfViewer {
.canvasWrapper {
overflow: hidden;
}
.page {
direction: ltr;
width: 816px;
height: 1056px;
margin: 1px auto -8px;
position: relative;
overflow: visible;
border: 9px solid transparent;
background-clip: content-box;
background-color: white;
canvas {
margin: 0;
display: block;
}
.adf-loadingIcon {
position: absolute;
display: block;
inset: 0;
width: 100px;
height: 100px;
left: 50%;
top: 50%;
margin-top: -50px;
margin-left: -50px;
font-size: 5px;
text-indent: -9999em;
border-top: 1.1em solid rgba(3, 0, 2, 0.2);
border-right: 1.1em solid rgba(3, 0, 2, 0.2);
border-bottom: 1.1em solid rgba(3, 0, 2, 0.2);
border-left: 1.1em solid #030002;
animation: load8 1.1s infinite linear;
border-radius: 50%;
&::after {
border-radius: 50%;
}
}
* {
padding: 0;
margin: 0;
}
}
&.adf-removePageBorders {
.adf-page {
margin: 0 auto 10px;
border: none;
}
}
.adf-pdf-viewer-annotation-tooltip.popup {
outline-color: var(--mat-sys-primary);
background-color: color-mix(in srgb, var(--mat-sys-primary) 30%, var(--mat-sys-on-primary));
margin-left: 40px;
width: max-content;
max-width: 300px;
opacity: 0;
pointer-events: none;
.popupContent {
display: inherit;
}
}
.textAnnotation {
&:hover,
&:focus-within {
/* stylelint-disable-next-line declaration-no-important */
z-index: 9999 !important; // important is required because pdfjs-dist adds style attribute to that tag
.adf-pdf-viewer-annotation-tooltip.popup {
opacity: 1;
pointer-events: auto;
}
}
}
}
.adf-hidden,
[hidden] {
display: none;
}
}
.adf-viewer-pdf-viewer {
overflow: auto;
-webkit-overflow-scrolling: touch;
position: absolute;
inset: 0;
outline: none;
}
html[dir='ltr'] .adf-viewer-pdf-viewer {
box-shadow: inset 1px 0 0 hsla(0deg, 0%, 100%, 0.05);
}
html[dir='rtl'] .adf-viewer-pdf-viewer {
box-shadow: inset -1px 0 0 hsla(0deg, 0%, 100%, 0.05);
}
@@ -0,0 +1,99 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PDF_VIEWER_COMPONENT, ViewerRenderComponent, UnitTestingUtils } from '@alfresco/adf-core';
import { PdfViewerComponent, PDFJS_MODULE, PDFJS_VIEWER_MODULE } from './pdf-viewer.component';
import { providePdfViewer } from '../../provide-pdf-viewer';
import pdfjsLibraryMock from '../mock/pdfjs-lib.mock';
describe('PdfViewer Integration with ViewerRenderComponent', () => {
let fixture: ComponentFixture<ViewerRenderComponent>;
let component: ViewerRenderComponent;
let testingUtils: UnitTestingUtils;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [ViewerRenderComponent],
providers: [
providePdfViewer(),
{ provide: PDFJS_MODULE, useValue: pdfjsLibraryMock },
{ provide: PDFJS_VIEWER_MODULE, useValue: class {} }
]
});
fixture = TestBed.createComponent(ViewerRenderComponent);
component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
});
afterEach(() => {
fixture.destroy();
});
it('should provide PdfViewerComponent via token', () => {
const token = TestBed.inject(PDF_VIEWER_COMPONENT);
expect(token).toBe(PdfViewerComponent);
});
it('should render real PdfViewerComponent for PDF files', async () => {
component.urlFile = 'fake-test-file.pdf';
component.ngOnChanges();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(component.viewerType).toBe('pdf');
expect(testingUtils.getByCSS('adf-pdf-viewer')).not.toBeNull();
});
it('should pass inputs to real PdfViewerComponent', async () => {
component.urlFile = 'fake-test-file.pdf';
component.allowThumbnails = true;
component.ngOnChanges();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const pdfViewer = testingUtils.getByCSS('adf-pdf-viewer');
expect(pdfViewer).not.toBeNull();
expect(pdfViewer.componentInstance.urlFile).toBe('fake-test-file.pdf');
expect(pdfViewer.componentInstance.allowThumbnails).toBe(true);
});
it('should show unknown format when token is not provided', async () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [ViewerRenderComponent],
providers: [{ provide: PDF_VIEWER_COMPONENT, useValue: null }]
});
fixture = TestBed.createComponent(ViewerRenderComponent);
component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
component.urlFile = 'fake-test-file.pdf';
component.ngOnChanges();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(component.viewerType).toBe('pdf');
expect(testingUtils.getByCSS('adf-pdf-viewer')).toBeNull();
expect(testingUtils.getByCSS('adf-viewer-unknown-format')).not.toBeNull();
});
});
@@ -0,0 +1,126 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { provideCoreAuthTesting } from '@alfresco/adf-core';
import { PdfViewerComponent } from './pdf-viewer.component';
import { RenderingQueueServices } from '../../services/rendering-queue.services';
import * as pdfjsLib from 'pdfjs-dist/build/pdf.min.mjs';
const MINIMAL_PDF_BASE64 =
'JVBERi0xLjcKCjEgMCBvYmogICUgZW50cnkgcG9pbnQKPDwKICAvVHlwZSAvQ2F0YWxvZwog' +
'IC9QYWdlcyAyIDAgUgo+PgplbmRvYmoKCjIgMCBvYmoKPDwKICAvVHlwZSAvUGFnZXMKICAv' +
'TWVkaWFCb3ggWyAwIDAgMjAwIDIwMCBdCiAgL0NvdW50IDEKICAvS2lkcyBbIDMgMCBSIF0K' +
'Pj4KZW5kb2JqCgozIDAgb2JqCjw8CiAgL1R5cGUgL1BhZ2UKICAvUGFyZW50IDIgMCBSCiAg' +
'L1Jlc291cmNlcyA8PAogICAgL0ZvbnQgPDwKICAgICAgL0YxIDQgMCBSIAogICAgPj4KICA+' +
'PgogIC9Db250ZW50cyA1IDAgUgo+PgplbmRvYmoKCjQgMCBvYmoKPDwKICAvVHlwZSAvRm9u' +
'dAogIC9TdWJ0eXBlIC9UeXBlMQogIC9CYXNlRm9udCAvVGltZXMtUm9tYW4KPj4KZW5kb2Jq' +
'Cgo1IDAgb2JqICAlIHBhZ2UgY29udGVudAo8PAogIC9MZW5ndGggNDQKPj4Kc3RyZWFtCkJU' +
'CjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8sIHdvcmxkISkgVGoKRVQKZW5kc3RyZWFtCmVu' +
'ZG9iagoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDEwIDAwMDAwIG4g' +
'CjAwMDAwMDAwNzkgMDAwMDAgbiAKMDAwMDAwMDE3MyAwMDAwMCBuIAowMDAwMDAwMzAxIDAw' +
'MDAwIG4gCjAwMDAwMDAzODAgMDAwMDAgbiAKdHJhaWxlcgo8PAogIC9TaXplIDYKICAvUm9v' +
'dCAxIDAgUgo+PgpzdGFydHhyZWYKNDkyCiUlRU9G';
/** @returns a Blob containing a minimal valid 1-page PDF */
function createRealPdfBlob(): Blob {
const binaryString = atob(MINIMAL_PDF_BASE64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: 'application/pdf' });
}
@Component({
imports: [PdfViewerComponent],
template: `<adf-pdf-viewer [blobFile]="blobFile" />`
})
class RealPdfTestHostComponent {
@ViewChild(PdfViewerComponent, { static: true })
pdfViewerComponent: PdfViewerComponent;
blobFile = createRealPdfBlob();
}
/**
* @param predicate - condition to wait for
* @param timeout - max wait time in ms
* @returns a promise that resolves when the predicate returns true
*/
function waitFor(predicate: () => boolean, timeout = 5000): Promise<void> {
const start = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
if (predicate()) {
resolve();
} else if (Date.now() - start > timeout) {
reject(new Error('waitFor timed out'));
} else {
setTimeout(check, 50);
}
};
check();
});
}
describe('PdfViewerComponent with real pdfjs-dist', () => {
let fixture: ComponentFixture<RealPdfTestHostComponent>;
let component: PdfViewerComponent;
beforeAll(async () => {
const workerModule = await import('pdfjs-dist/build/pdf.worker.min.mjs');
(globalThis as any).pdfjsWorker = { WorkerMessageHandler: workerModule.WorkerMessageHandler };
});
afterAll(() => {
delete (globalThis as any).pdfjsWorker;
});
beforeEach(async () => {
pdfjsLib.GlobalWorkerOptions.workerSrc = '';
TestBed.configureTestingModule({
imports: [RealPdfTestHostComponent],
providers: [provideCoreAuthTesting(), { provide: MatDialog, useValue: { open: () => {} } }, RenderingQueueServices]
});
fixture = TestBed.createComponent(RealPdfTestHostComponent);
component = fixture.componentInstance.pdfViewerComponent;
spyOn(component as any, 'setupPdfJsWorker').and.resolveTo();
});
afterEach(() => {
fixture.destroy();
});
it('should load a real PDF blob and detect page count', async () => {
fixture.detectChanges();
await waitFor(() => component.totalPages > 0);
expect(component.totalPages).toBe(1);
});
it('should emit pagesLoaded when a real PDF is rendered', async () => {
const pagesLoadedSpy = spyOn(component.pagesLoaded, 'emit');
fixture.detectChanges();
await waitFor(() => pagesLoadedSpy.calls.count() > 0);
expect(pagesLoadedSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
<div class="adf-pdf-viewer__container">
<ng-container *ngIf="showThumbnails">
<div class="adf-pdf-viewer__thumbnails">
<div class="adf-thumbnails-template__container">
<div class="adf-thumbnails-template__buttons">
<button mat-icon-button
data-automation-id='adf-thumbnails-close'
(click)="toggleThumbnails()"
[attr.aria-label]="'ADF_VIEWER.ARIA.THUMBNAILS_PANLEL_CLOSE' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.CLOSE' | translate }}">
<mat-icon adf-icon="close" />
</button>
</div>
<ng-container *ngIf="thumbnailsTemplate">
<ng-container *ngTemplateOutlet="thumbnailsTemplate;context:pdfThumbnailsContext" />
</ng-container>
<adf-pdf-thumbnails *ngIf="!thumbnailsTemplate && !isPanelDisabled"
(close)="toggleThumbnails()"
[pdfViewer]="pdfViewer" />
</div>
</div>
</ng-container>
<div class="adf-pdf-viewer__content">
<div [id]="randomPdfId + '-viewer-pdf-viewer'"
class="adf-viewer-pdf-viewer"
(window:resize)="onResize()">
<div [id]="randomPdfId + '-viewer-viewerPdf'"
class="adf-pdfViewer pdfViewer"
role="document"
tabindex="0"
aria-expanded="true">
<div id="loader-container" class="adf-loader-container">
<div class="adf-loader-item">
<mat-progress-bar [attr.aria-label]="'ADF_VIEWER.ARIA.LOADING' | translate"
class="adf-loader-item-progress-bar" mode="indeterminate" />
</div>
</div>
</div>
</div>
</div>
</div>
<div class="adf-pdf-viewer__toolbar" *ngIf="showToolbar" [ngStyle]="documentOverflow && {bottom: '25px'}">
<adf-toolbar>
<ng-container *ngIf="allowThumbnails">
<button mat-icon-button
[attr.aria-label]="'ADF_VIEWER.ARIA.THUMBNAILS' | translate"
[attr.aria-expanded]="showThumbnails"
data-automation-id="adf-thumbnails-button"
[disabled]="isPanelDisabled"
(click)="toggleThumbnails()">
<mat-icon adf-icon="dashboard" />
</button>
<adf-toolbar-divider />
</ng-container>
<button id="viewer-previous-page-button"
title="{{ 'ADF_VIEWER.ARIA.PREVIOUS_PAGE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.PREVIOUS_PAGE' | translate }}"
mat-icon-button
(click)="previousPage()">
<mat-icon adf-icon="keyboard_arrow_up" />
</button>
<button id="viewer-next-page-button"
title="{{ 'ADF_VIEWER.ARIA.NEXT_PAGE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.NEXT_PAGE' | translate }}"
mat-icon-button
(click)="nextPage()">
<mat-icon adf-icon="keyboard_arrow_down" />
</button>
<div class="adf-pdf-viewer__toolbar-page-selector">
<label for="page-selector">{{ 'ADF_VIEWER.PAGE_LABEL.SHOWING' | translate }}</label>
<input #page
id="page-selector"
type="text"
data-automation-id="adf-page-selector"
pattern="-?[0-9]*(\.[0-9]+)?"
value="{{ displayPage }}"
[attr.aria-label]="'ADF_VIEWER.PAGE_LABEL.PAGE_SELECTOR_LABEL' | translate"
(keyup.enter)="inputPage(page.value)">
<span>{{ 'ADF_VIEWER.PAGE_LABEL.OF' | translate }} {{ totalPages }}</span>
</div>
<div class="adf-pdf-viewer__toolbar-page-scale" data-automation-id="adf-page-scale">
{{ currentScaleText }}
</div>
<button id="viewer-zoom-in-button"
title="{{ 'ADF_VIEWER.ARIA.ZOOM_IN' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.ZOOM_IN' | translate }}"
mat-icon-button
(click)="zoomIn()">
<mat-icon adf-icon="zoom_in" />
</button>
<button id="viewer-zoom-out-button"
title="{{ 'ADF_VIEWER.ARIA.ZOOM_OUT' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.ZOOM_OUT' | translate }}"
mat-icon-button
(click)="zoomOut()">
<mat-icon adf-icon="zoom_out" />
</button>
<button id="viewer-scale-page-button"
role="button" aria-pressed="true"
title="{{ 'ADF_VIEWER.ARIA.FIT_PAGE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.FIT_PAGE' | translate }}"
mat-icon-button
(click)="pageFit()">
<mat-icon adf-icon="zoom_out_map" />
</button>
</adf-toolbar>
</div>
@@ -0,0 +1,142 @@
@use 'styles/mat-selectors' as ms;
.adf-pdf-viewer {
width: 100%;
height: 100%;
margin: 0;
.adf-loader-container {
display: flex;
flex-direction: row;
height: 100%;
}
&__thumbnails {
position: relative;
height: 100%;
width: 190px;
background-color: var(--mat-sys-surface-container-low);
display: flex;
flex-direction: column;
padding: 0;
.adf-info-drawer-layout {
display: flex;
flex-direction: column;
flex: 1;
background: var(--mat-sys-surface-container);
}
.adf-info-drawer-layout-header {
margin-bottom: 0;
}
.adf-info-drawer-layout-content {
padding: 0;
height: 100%;
overflow: hidden;
}
.adf-info-drawer-content {
height: 100%;
}
.adf-info-drawer-layout-content > *:last-child {
height: 100%;
overflow: hidden;
}
}
.adf-thumbnails-template {
&__container {
display: flex;
flex-direction: column;
height: 100%;
}
&__buttons {
height: 45px;
justify-content: flex-end;
align-items: flex-end;
display: flex;
}
}
&__container {
display: flex;
height: 100%;
min-height: 1px;
}
&__content {
flex: 1 1 auto;
position: relative;
}
.adf-loader-item {
margin: auto;
max-height: 100px;
max-width: 300px;
.adf-loader-item-progress-bar {
max-width: 300px;
margin: 0;
position: absolute;
top: 50%;
left: 50%;
/* stylelint-disable-next-line property-no-vendor-prefix */
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
}
&__toolbar {
z-index: 100;
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
.adf-toolbar #{ms.$mat-toolbar} {
max-height: 48px;
background-color: var(--mat-sys-surface);
border-width: 0;
border-radius: 2px;
box-shadow:
0 2px 2px 0 rgba(0, 0, 0, 20.4),
0 0 2px 0 rgba(0, 0, 0, 0.12);
}
&-page-selector {
padding-left: 10px;
padding-right: 10px;
white-space: nowrap;
& > input {
border: 1px solid var(--mat-sys-outline-variant);
background-color: var(--mat-sys-surface);
color: inherit;
padding: 5px;
height: 24px;
line-height: 24px;
text-align: right;
width: 33px;
margin: 0 5px;
outline-width: 1px;
outline-color: var(--mat-sys-surface-container);
}
}
&-page-scale {
cursor: default;
width: 79px;
height: 24px;
font-size: var(--mat-sys-body-small-size);
border: 1px solid var(--mat-sys-outline-variant);
text-align: center;
line-height: 24px;
margin-left: 4px;
margin-right: 4px;
}
}
}
@@ -0,0 +1,700 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LEFT_ARROW, RIGHT_ARROW } from '@angular/cdk/keycodes';
import { Component, SimpleChange, SimpleChanges, ViewChild } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
import { AppConfigService, EventMock, UnitTestingUtils, provideCoreAuthTesting } from '@alfresco/adf-core';
import { RenderingQueueServices } from '../../services/rendering-queue.services';
import { PdfThumbListComponent } from '../pdf-viewer-thumbnails/pdf-viewer-thumbnails.component';
import { PDFJS_MODULE, PDFJS_VIEWER_MODULE, PdfViewerComponent } from './pdf-viewer.component';
import pdfjsLibraryMock, { annotations } from '../mock/pdfjs-lib.mock';
import { TranslateService } from '@ngx-translate/core';
declare const pdfjsLib: {
PasswordResponses: {
NEED_PASSWORD: number;
INCORRECT_PASSWORD: number;
};
};
@Component({
selector: 'adf-url-test-component',
imports: [PdfViewerComponent],
template: ` <adf-pdf-viewer [allowThumbnails]="true" [showToolbar]="true" [urlFile]="urlFile" /> `
})
class UrlTestComponent {
@ViewChild(PdfViewerComponent, { static: true })
pdfViewerComponent: PdfViewerComponent;
urlFile: string;
constructor() {
this.urlFile = './fake-test-file.pdf';
}
}
@Component({
selector: 'adf-url-test-password-component',
imports: [PdfViewerComponent],
template: ` <adf-pdf-viewer [allowThumbnails]="true" [showToolbar]="true" [urlFile]="urlFile" /> `
})
class UrlTestPasswordComponent {
@ViewChild(PdfViewerComponent, { static: true })
pdfViewerComponent: PdfViewerComponent;
urlFile: string;
constructor() {
this.urlFile = './fake-test-password-file.pdf';
}
}
@Component({
imports: [PdfViewerComponent],
template: ` <adf-pdf-viewer [allowThumbnails]="true" [showToolbar]="true" [blobFile]="blobFile" /> `
})
class BlobTestComponent {
@ViewChild(PdfViewerComponent, { static: true })
pdfViewerComponent: PdfViewerComponent;
blobFile: Blob;
constructor() {
this.blobFile = this.createFakeBlob();
}
createFakeBlob(): Blob {
const pdfData = atob(
'JVBERi0xLjcKCjEgMCBvYmogICUgZW50cnkgcG9pbnQKPDwKICAvVHlwZSAvQ2F0YWxvZwog' +
'IC9QYWdlcyAyIDAgUgo+PgplbmRvYmoKCjIgMCBvYmoKPDwKICAvVHlwZSAvUGFnZXMKICAv' +
'TWVkaWFCb3ggWyAwIDAgMjAwIDIwMCBdCiAgL0NvdW50IDEKICAvS2lkcyBbIDMgMCBSIF0K' +
'Pj4KZW5kb2JqCgozIDAgb2JqCjw8CiAgL1R5cGUgL1BhZ2UKICAvUGFyZW50IDIgMCBSCiAg' +
'L1Jlc291cmNlcyA8PAogICAgL0ZvbnQgPDwKICAgICAgL0YxIDQgMCBSIAogICAgPj4KICA+' +
'PgogIC9Db250ZW50cyA1IDAgUgo+PgplbmRvYmoKCjQgMCBvYmoKPDwKICAvVHlwZSAvRm9u' +
'dAogIC9TdWJ0eXBlIC9UeXBlMQogIC9CYXNlRm9udCAvVGltZXMtUm9tYW4KPj4KZW5kb2Jq' +
'Cgo1IDAgb2JqICAlIHBhZ2UgY29udGVudAo8PAogIC9MZW5ndGggNDQKPj4Kc3RyZWFtCkJU' +
'CjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8sIHdvcmxkISkgVGoKRVQKZW5kc3RyZWFtCmVu' +
'ZG9iagoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDEwIDAwMDAwIG4g' +
'CjAwMDAwMDAwNzkgMDAwMDAgbiAKMDAwMDAwMDE3MyAwMDAwMCBuIAowMDAwMDAwMzAxIDAw' +
'MDAwIG4gCjAwMDAwMDAzODAgMDAwMDAgbiAKdHJhaWxlcgo8PAogIC9TaXplIDYKICAvUm9v' +
'dCAxIDAgUgo+PgpzdGFydHhyZWYKNDkyCiUlRU9G'
);
return new Blob([pdfData], { type: 'application/pdf' });
}
}
describe('Test PdfViewer component', () => {
let component: PdfViewerComponent;
let fixture: ComponentFixture<PdfViewerComponent>;
let change: SimpleChange;
let dialog: MatDialog;
let testingUtils: UnitTestingUtils;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [PdfViewerComponent],
providers: [
provideCoreAuthTesting(),
{
provide: MatDialog,
useValue: {
open: () => {}
}
},
RenderingQueueServices
]
});
fixture = TestBed.createComponent(PdfViewerComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
dialog = TestBed.inject(MatDialog);
component = fixture.componentInstance;
component.showToolbar = true;
component.inputPage('1');
fixture.detectChanges();
await fixture.whenStable();
});
afterAll(() => {
fixture.destroy();
});
it('should Loader be present', () => {
expect(testingUtils.getByCSS('.adf-loader-container')).not.toBeNull();
});
describe('Required values', () => {
it('should thrown an error If urlFile is not present', () => {
change = new SimpleChange(null, null, true);
expect(() => {
component.ngOnChanges({ urlFile: change });
}).toThrow(new Error('Attribute urlFile or blobFile is required'));
});
it('should If blobFile is not present thrown an error ', () => {
change = new SimpleChange(null, null, true);
expect(() => {
component.ngOnChanges({ blobFile: change });
}).toThrow(new Error('Attribute urlFile or blobFile is required'));
});
});
describe('View with url file', () => {
let fixtureUrlTestComponent: ComponentFixture<UrlTestComponent>;
let elementUrlTestComponent: HTMLElement;
beforeEach(async () => {
fixtureUrlTestComponent = TestBed.createComponent(UrlTestComponent);
elementUrlTestComponent = fixtureUrlTestComponent.nativeElement;
testingUtils.setDebugElement(fixtureUrlTestComponent.debugElement);
fixtureUrlTestComponent.detectChanges();
await fixtureUrlTestComponent.whenStable();
});
afterEach(() => {
document.body.removeChild(elementUrlTestComponent);
});
it('should Canvas be present', async () => {
fixtureUrlTestComponent.detectChanges();
await fixtureUrlTestComponent.whenStable();
expect(testingUtils.getByCSS('.adf-pdfViewer')).not.toBeNull();
expect(testingUtils.getByCSS('.adf-viewer-pdf-viewer')).not.toBeNull();
});
it('should Input Page elements be present', async () => {
fixtureUrlTestComponent.detectChanges();
await fixtureUrlTestComponent.whenStable();
expect(testingUtils.getByCSS('.viewer-pagenumber-input')).toBeDefined();
expect(testingUtils.getByCSS('.viewer-total-pages')).toBeDefined();
expect(testingUtils.getByCSS('#viewer-previous-page-button')).not.toBeNull();
expect(testingUtils.getByCSS('#viewer-next-page-button')).not.toBeNull();
});
it('should Toolbar be hide if showToolbar is false', async () => {
component.showToolbar = false;
fixtureUrlTestComponent.detectChanges();
await fixtureUrlTestComponent.whenStable();
expect(testingUtils.getByCSS('.viewer-toolbar-command')).toBeNull();
expect(testingUtils.getByCSS('.viewer-toolbar-pagination')).toBeNull();
});
});
describe('View with blob file', () => {
let fixtureBlobTestComponent: ComponentFixture<BlobTestComponent>;
let elementBlobTestComponent: HTMLElement;
beforeEach(async () => {
fixtureBlobTestComponent = TestBed.createComponent(BlobTestComponent);
elementBlobTestComponent = fixtureBlobTestComponent.nativeElement;
testingUtils.setDebugElement(fixtureBlobTestComponent.debugElement);
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
});
afterEach(() => {
document.body.removeChild(elementBlobTestComponent);
});
it('should Canvas be present', async () => {
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
expect(testingUtils.getByCSS('.adf-pdfViewer')).not.toBeNull();
expect(testingUtils.getByCSS('.adf-viewer-pdf-viewer')).not.toBeNull();
});
it('should Next an Previous Buttons be present', async () => {
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
expect(testingUtils.getByCSS('#viewer-previous-page-button')).not.toBeNull();
expect(testingUtils.getByCSS('#viewer-next-page-button')).not.toBeNull();
});
it('should Input Page elements be present', async () => {
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
/* cspell:disable-next-line */
expect(testingUtils.getByCSS('.adf-viewer-pagenumber-input')).toBeDefined();
expect(testingUtils.getByCSS('.adf-viewer-total-pages')).toBeDefined();
expect(testingUtils.getByCSS('#viewer-previous-page-button')).not.toBeNull();
expect(testingUtils.getByCSS('#viewer-next-page-button')).not.toBeNull();
});
it('should Toolbar be hide if showToolbar is false', async () => {
fixtureBlobTestComponent.componentInstance.pdfViewerComponent.showToolbar = false;
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
expect(testingUtils.getByCSS('.viewer-toolbar-command')).toBeNull();
expect(testingUtils.getByCSS('.viewer-toolbar-pagination')).toBeNull();
});
});
describe('Password protection dialog', () => {
let fixtureUrlTestPasswordComponent: ComponentFixture<UrlTestPasswordComponent>;
let componentUrlTestPasswordComponent: UrlTestPasswordComponent;
describe('Open password dialog', () => {
beforeEach(async () => {
fixtureUrlTestPasswordComponent = TestBed.createComponent(UrlTestPasswordComponent);
componentUrlTestPasswordComponent = fixtureUrlTestPasswordComponent.componentInstance;
spyOn(dialog, 'open').and.callFake((_dialogComponent: unknown, context: { data: { reason: number } }) => {
if (context.data.reason === pdfjsLib.PasswordResponses.NEED_PASSWORD) {
return {
afterClosed: () => of('wrong_password')
} as ReturnType<MatDialog['open']>;
}
if (context.data.reason === pdfjsLib.PasswordResponses.INCORRECT_PASSWORD) {
return {
afterClosed: () => of('password')
} as ReturnType<MatDialog['open']>;
}
return undefined;
});
fixtureUrlTestPasswordComponent.detectChanges();
await fixtureUrlTestPasswordComponent.whenStable();
});
afterEach(() => {
document.body.removeChild(fixtureUrlTestPasswordComponent.nativeElement);
});
it('should try to access protected pdf', async () => {
componentUrlTestPasswordComponent.pdfViewerComponent.onPdfPassword(() => {}, pdfjsLib.PasswordResponses.NEED_PASSWORD);
fixture.detectChanges();
await fixture.whenStable();
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('should raise dialog asking for password', async () => {
componentUrlTestPasswordComponent.pdfViewerComponent.onPdfPassword(() => {}, pdfjsLib.PasswordResponses.NEED_PASSWORD);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(dialog.open['calls'].all()[0].args[1].data).toEqual({
reason: pdfjsLib.PasswordResponses.NEED_PASSWORD
});
});
it('it should raise dialog with incorrect password', async () => {
componentUrlTestPasswordComponent.pdfViewerComponent.onPdfPassword(() => {}, pdfjsLib.PasswordResponses.INCORRECT_PASSWORD);
fixture.detectChanges();
await fixture.whenStable();
expect(dialog.open['calls'].all()[0].args[1].data).toEqual({
reason: pdfjsLib.PasswordResponses.INCORRECT_PASSWORD
});
});
});
describe('Close password dialog ', () => {
beforeEach(async () => {
fixtureUrlTestPasswordComponent = TestBed.createComponent(UrlTestPasswordComponent);
componentUrlTestPasswordComponent = fixtureUrlTestPasswordComponent.componentInstance;
spyOn(dialog, 'open').and.callFake(
() =>
({
afterClosed: () => of('')
}) as ReturnType<MatDialog['open']>
);
spyOn(componentUrlTestPasswordComponent.pdfViewerComponent.close, 'emit');
fixtureUrlTestPasswordComponent.detectChanges();
await fixtureUrlTestPasswordComponent.whenStable();
});
afterEach(() => {
document.body.removeChild(fixtureUrlTestPasswordComponent.nativeElement);
});
it('should try to access protected pdf', async () => {
componentUrlTestPasswordComponent.pdfViewerComponent.onPdfPassword(() => {}, pdfjsLib.PasswordResponses.NEED_PASSWORD);
fixture.detectChanges();
await fixture.whenStable();
expect(componentUrlTestPasswordComponent.pdfViewerComponent.close.emit).toHaveBeenCalledWith();
});
});
});
});
describe('Test PdfViewer - Zoom customization', () => {
let fixture: ComponentFixture<PdfViewerComponent>;
let component: PdfViewerComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [PdfViewerComponent],
providers: [
provideCoreAuthTesting(),
{
provide: MatDialog,
useValue: {
open: () => {}
}
},
RenderingQueueServices
]
});
fixture = TestBed.createComponent(PdfViewerComponent);
component = fixture.componentInstance;
});
afterAll(() => {
fixture.destroy();
});
it('should use the custom zoom if it is present in the app.config', () => {
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer.pdf-viewer-scaling'] = 80;
expect(component.getUserScaling()).toBe(0.8);
});
it('should use the minimum scale zoom if the value given in app.config is less than the minimum allowed scale', () => {
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer.pdf-viewer-scaling'] = 10;
fixture.detectChanges();
expect(component.getUserScaling()).toBe(0.25);
});
it('should use the maximum scale zoom if the value given in app.config is greater than the maximum allowed scale', () => {
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer.pdf-viewer-scaling'] = 5555;
fixture.detectChanges();
expect(component.getUserScaling()).toBe(10);
});
});
describe('Test PdfViewer - User interaction', () => {
let fixture: ComponentFixture<PdfViewerComponent>;
let component: PdfViewerComponent;
let testingUtils: UnitTestingUtils;
let pdfViewerSpy: jasmine.Spy;
beforeEach(fakeAsync(() => {
pdfViewerSpy = jasmine.createSpy('PDFViewer').and.returnValue({
setDocument: jasmine.createSpy().and.returnValue({
loadingTask: () => ({
destroy: () => Promise.resolve()
}),
promise: new Promise((resolve) => {
resolve({
numPages: 6,
getPage: () => 'fakePage'
});
})
}),
forceRendering: jasmine.createSpy(),
update: jasmine.createSpy(),
currentScaleValue: 1,
_currentPageNumber: 1,
_pages: [{ width: 100, height: 100, scale: 1 }]
});
TestBed.configureTestingModule({
imports: [PdfViewerComponent],
providers: [
provideCoreAuthTesting(),
{
provide: MatDialog,
useValue: {
open: () => {}
}
},
RenderingQueueServices,
{ provide: PDFJS_VIEWER_MODULE, useValue: pdfViewerSpy },
{ provide: PDFJS_MODULE, useValue: pdfjsLibraryMock }
]
});
fixture = TestBed.createComponent(PdfViewerComponent);
component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer.pdf-viewer-scaling'] = 10;
component['setupPdfJsWorker'] = () => Promise.resolve();
component.urlFile = './fake-test-file.pdf';
fixture.detectChanges();
component.ngOnChanges({
urlFile: new SimpleChange(null, './fake-test-file.pdf', true)
});
flush();
}));
afterAll(() => {
fixture.destroy();
});
it('should init the viewer with annotation mode enabled', () => {
expect(pdfViewerSpy).toHaveBeenCalledWith(jasmine.objectContaining({ annotationMode: 1 }));
});
it('should Total number of pages be loaded', () => {
expect(component.totalPages).toBe(6);
});
it('should nextPage move to the next page', () => {
testingUtils.clickByCSS('#viewer-next-page-button');
expect(component.displayPage).toBe(2);
});
it('should event RIGHT_ARROW keyboard change pages', () => {
fixture.detectChanges();
EventMock.keyDown(RIGHT_ARROW);
expect(component.displayPage).toBe(2);
});
it('should event LEFT_ARROW keyboard change pages', () => {
component.inputPage('2');
EventMock.keyDown(LEFT_ARROW);
expect(component.displayPage).toBe(1);
});
it('should previous page move to the previous page', () => {
testingUtils.clickByCSS('#viewer-next-page-button');
testingUtils.clickByCSS('#viewer-next-page-button');
testingUtils.clickByCSS('#viewer-previous-page-button');
expect(component.displayPage).toBe(2);
});
it('should previous page not move to the previous page if is page 1', () => {
component.previousPage();
expect(component.displayPage).toBe(1);
});
it('should Input page move to the inserted page', () => {
component.inputPage('2');
expect(component.displayPage).toBe(2);
});
it('should configure PDF.js with the correct wasmUrl', () => {
const changes: SimpleChanges = {
blobFile: new SimpleChange(null, component.blobFile, true)
};
component.ngOnChanges(changes);
const getDocumentSpy = pdfjsLibraryMock.getDocument;
expect(getDocumentSpy).toHaveBeenCalled();
const loadingArgs = getDocumentSpy.calls.mostRecent().args[0];
expect(loadingArgs.wasmUrl).toBe('./wasm/');
});
describe('Zoom', () => {
it('should zoom in increment the scale value', () => {
const zoomBefore = component.pdfViewer.currentScaleValue;
testingUtils.clickByCSS('#viewer-zoom-in-button');
expect(component.currentScaleMode).toBe('auto');
const currentZoom = component.pdfViewer.currentScaleValue;
expect(zoomBefore < currentZoom).toBe(true);
});
it('should zoom out decrement the scale value', () => {
testingUtils.clickByCSS('#viewer-zoom-in-button');
const zoomBefore = component.pdfViewer.currentScaleValue;
testingUtils.clickByCSS('#viewer-zoom-out-button');
expect(component.currentScaleMode).toBe('auto');
const currentZoom = component.pdfViewer.currentScaleValue;
expect(zoomBefore > currentZoom).toBe(true);
});
it('should it-in button toggle page-fit and auto scale mode', fakeAsync(() => {
tick(250);
expect(component.currentScaleMode).toBe('init');
testingUtils.clickByCSS('#viewer-scale-page-button');
expect(component.currentScaleMode).toBe('page-fit');
testingUtils.clickByCSS('#viewer-scale-page-button');
expect(component.currentScaleMode).toBe('auto');
testingUtils.clickByCSS('#viewer-scale-page-button');
expect(component.currentScaleMode).toBe('page-fit');
}), 300);
});
describe('Resize interaction', () => {
it('should resize event trigger setScaleUpdatePages', () => {
spyOn(component, 'onResize');
EventMock.resizeMobileView();
expect(component.onResize).toHaveBeenCalled();
});
});
describe('Thumbnails', () => {
it('should have own context', () => {
expect(component.pdfThumbnailsContext.viewer).not.toBeNull();
});
it('should open thumbnails panel', () => {
expect(testingUtils.getByCSS('.adf-pdf-viewer__thumbnails')).toBeNull();
component.toggleThumbnails();
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-pdf-viewer__thumbnails')).not.toBeNull();
});
it('should not render PdfThumbListComponent during initialization of new pdfViewer', () => {
component.toggleThumbnails();
component.urlFile = 'file.pdf';
fixture.detectChanges();
expect(fixture.debugElement.query(By.directive(PdfThumbListComponent))).toBeNull();
});
});
describe('Viewer events', () => {
it('should react on the emit of pageChange event', () => {
const args = {
pageNumber: 6,
source: {
container: document.getElementById(`${component.randomPdfId}-viewer-pdf-viewer`)
}
};
component.onPageChange(args);
expect(component.displayPage).toBe(6);
expect(component.page).toBe(6);
});
it('should react on the emit of pagesLoaded event', () => {
expect(component.isPanelDisabled).toBe(true);
component.onPagesLoaded();
expect(component.isPanelDisabled).toBe(false);
});
});
describe('Annotations', () => {
const annotationImageAlt = 'Note Annotation';
const annotationAttribute = 'data-annotation-id';
let annotationElement: HTMLElement;
let annotationImageElement: HTMLImageElement;
let documentContainer: HTMLDivElement;
const dispatchAnnotationLayerRenderedEvent = (): void => {
pdfViewerSpy.calls.mostRecent().args[0].eventBus.dispatch('annotationlayerrendered', {
pageNumber: 1,
source: {
div: documentContainer
}
});
tick();
};
const getAnnotationPopupElement = (): HTMLElement => annotationElement.querySelector('.adf-pdf-viewer-annotation-tooltip');
const getAnnotationTitle = (): string => annotationElement.querySelector('.title').textContent;
const getAnnotationDate = (): string => annotationElement.querySelector('.popupDate')?.textContent;
const getAnnotationContent = (): string => annotationElement.querySelector('.popupContent').textContent;
beforeEach(() => {
documentContainer = document.createElement('div');
annotationImageElement = document.createElement('img');
annotationElement = document.createElement('section');
annotationElement.setAttribute(annotationAttribute, 'R13');
annotationElement.append(annotationImageElement);
documentContainer.append(annotationElement);
spyOn(TestBed.inject(TranslateService), 'instant').withArgs('ADF_VIEWER.ARIA.NOTE_ANNOTATION_IMG').and.returnValue(annotationImageAlt);
});
it('should have corrected image in annotation popup', fakeAsync(() => {
dispatchAnnotationLayerRenderedEvent();
expect(annotationImageElement.src).toBe(
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGV' +
'pZ2h0PSIyNCI+PHBhdGggZD0iTTIgMmgxNHYxNEgyeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0zIDNoMTJ2MTJIM3oiIGZpbGw9Ii' +
'NmZmRiMDAiLz48cGF0aCBkPSJNNSA1aDh2OGgtOHoiIGZpbGw9IiNmZmJiMDAiLz48L3N2Zz4='
);
expect(annotationImageElement.alt).toBe(annotationImageAlt);
}));
it('should have corrected content in annotation popup', fakeAsync(() => {
dispatchAnnotationLayerRenderedEvent();
expect(getAnnotationTitle()).toBe('Annotation title');
const dateText = getAnnotationDate();
// Date format may vary by locale, so check it contains the key parts
expect(dateText).toMatch(/2026/);
expect(dateText).toMatch(/10:41:06|10:41:6/);
expect(getAnnotationContent()).toBe('Annotation contents');
expect(getAnnotationPopupElement()).toBeDefined();
}));
it('should have corrected content in annotation popup if there is no modification date', fakeAsync(() => {
annotations[0].modificationDate = null;
dispatchAnnotationLayerRenderedEvent();
expect(getAnnotationTitle()).toBe('Annotation title');
expect(getAnnotationDate()).toBeUndefined();
expect(getAnnotationContent()).toBe('Annotation contents');
expect(getAnnotationPopupElement()).toBeDefined();
}));
it('should not have corrected content', fakeAsync(() => {
const annotationPopupElement = document.createElement('section');
annotationPopupElement.setAttribute(annotationAttribute, 'R1');
documentContainer.append(annotationPopupElement);
dispatchAnnotationLayerRenderedEvent();
expect(getAnnotationPopupElement()).toBeNull();
}));
});
});
@@ -0,0 +1,757 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable no-underscore-dangle */
/* eslint-disable @angular-eslint/no-output-native */
import { NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
import {
Component,
EventEmitter,
HostListener,
inject,
InjectionToken,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
TemplateRef,
ViewEncapsulation
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { from, Subject, switchMap } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AppConfigService, IconModule, ToolbarComponent, ToolbarDividerComponent } from '@alfresco/adf-core';
import { RenderingQueueServices } from '../../services/rendering-queue.services';
import { PdfPasswordDialogComponent } from '../pdf-viewer-password-dialog/pdf-viewer-password-dialog';
import { PdfThumbListComponent } from '../pdf-viewer-thumbnails/pdf-viewer-thumbnails.component';
import * as pdfjsLib from 'pdfjs-dist/build/pdf.min.mjs';
import { PDFDateString } from 'pdfjs-dist/build/pdf.min.mjs';
import { EventBus, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs';
import { OnProgressParameters, PDFDocumentLoadingTask, PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist/types/src/display/api';
export type PdfScaleMode = 'init' | 'page-actual' | 'page-width' | 'page-height' | 'page-fit' | 'auto';
export interface PageChangingEvent {
pageNumber: number;
source?: {
container?: {
id?: string;
};
};
}
export interface PdfThumbnailPage {
id: number;
getWidth: () => number;
getHeight: () => number;
getPage: () => Promise<PDFPageProxy>;
}
export interface PdfAnnotationData {
titleObj?: {
str?: string;
};
modificationDate?: string;
}
export interface PdfAnnotationWithTitle extends PdfAnnotationData {
titleObj: {
str: string;
};
}
export const PDFJS_MODULE = new InjectionToken('PDFJS_MODULE', { factory: () => pdfjsLib });
export const PDFJS_VIEWER_MODULE = new InjectionToken('PDFJS_VIEWER_MODULE', { factory: () => PDFViewer });
@Component({
selector: 'adf-pdf-viewer',
templateUrl: './pdf-viewer.component.html',
styleUrls: ['./pdf-viewer-host.component.scss', './pdf-viewer.component.scss'],
providers: [RenderingQueueServices],
host: { class: 'adf-pdf-viewer' },
imports: [
MatButtonModule,
IconModule,
TranslatePipe,
PdfThumbListComponent,
NgIf,
NgTemplateOutlet,
MatProgressBarModule,
NgStyle,
ToolbarComponent,
ToolbarDividerComponent
],
encapsulation: ViewEncapsulation.None
})
export class PdfViewerComponent implements OnChanges, OnDestroy {
@Input()
urlFile: string;
@Input()
blobFile: Blob;
@Input()
fileName: string;
@Input()
showToolbar: boolean = true;
@Input()
allowThumbnails = false;
@Input()
thumbnailsTemplate: TemplateRef<unknown> = null;
@Input()
cacheType: string = '';
@Output()
rendered = new EventEmitter<void>();
@Output()
error = new EventEmitter<void>();
@Output()
close = new EventEmitter<void>();
@Output()
pagesLoaded = new EventEmitter<void>();
page: number;
displayPage: number;
totalPages: number;
loadingPercent: number;
pdfViewer: PDFViewer;
pdfJsWorkerUrl: string;
pdfJsWorkerInstance: Worker;
currentScaleMode: PdfScaleMode = 'init';
MAX_AUTO_SCALE: number = 1.25;
DEFAULT_SCALE_DELTA: number = 1.1;
MIN_SCALE: number = 0.25;
MAX_SCALE: number = 10.0;
loadingTask: PDFDocumentLoadingTask;
isPanelDisabled = true;
showThumbnails: boolean = false;
pdfThumbnailsContext: { viewer: PDFViewer | null } = { viewer: null };
randomPdfId: string;
documentOverflow = false;
get currentScaleText(): string {
const currentScaleValueStr = this.pdfViewer?.currentScaleValue;
const scaleNumber = Number(currentScaleValueStr);
const currentScaleText = scaleNumber ? `${Math.round(scaleNumber * 100)}%` : '';
return currentScaleText;
}
private readonly pdfjsLib = inject(PDFJS_MODULE);
private readonly pdfjsViewer = inject(PDFJS_VIEWER_MODULE);
private readonly eventBus = new EventBus();
private readonly pdfjsDefaultOptions = {
disableAutoFetch: true,
disableStream: true,
cMapUrl: './cmaps/',
cMapPacked: true,
wasmUrl: './wasm/'
};
private readonly pdfjsWorkerDestroy$ = new Subject<boolean>();
private readonly dialog = inject(MatDialog);
private readonly renderingQueueServices = inject(RenderingQueueServices);
private readonly appConfigService = inject(AppConfigService);
private readonly translateService = inject(TranslateService);
constructor() {
// needed to preserve "this" context
this.onPageChange = this.onPageChange.bind(this);
this.onPagesLoaded = this.onPagesLoaded.bind(this);
this.onPageRendered = this.onPageRendered.bind(this);
this.randomPdfId = Date.now().toString();
this.pdfjsWorkerDestroy$
.pipe(
catchError(() => null),
switchMap(() => from(this.destroyPfdJsWorker()))
)
.subscribe(() => {});
}
getUserScaling(): number {
let scaleConfig = this.appConfigService.get<number>('adf-viewer.pdf-viewer-scaling', undefined);
if (scaleConfig) {
scaleConfig = scaleConfig / 100;
scaleConfig = this.checkLimits(scaleConfig);
}
return scaleConfig;
}
checkLimits(scaleConfig: number): number {
if (scaleConfig > this.MAX_SCALE) {
return this.MAX_SCALE;
} else if (scaleConfig < this.MIN_SCALE) {
return this.MIN_SCALE;
} else {
return scaleConfig;
}
}
ngOnChanges(changes: SimpleChanges) {
const blobFile = changes['blobFile'];
if (blobFile?.currentValue) {
const reader = new FileReader();
reader.onload = async () => {
const pdfOptions = {
...this.pdfjsDefaultOptions,
data: reader.result,
withCredentials: this.appConfigService.get<boolean>('auth.withCredentials', undefined),
isEvalSupported: false
};
this.executePdf(pdfOptions);
};
reader.readAsArrayBuffer(blobFile.currentValue);
}
const urlFile = changes['urlFile'];
if (urlFile?.currentValue) {
const pdfOptions: {
url: string;
withCredentials: boolean | undefined;
isEvalSupported: boolean;
httpHeaders?: { 'Cache-Control': string };
} & typeof this.pdfjsDefaultOptions = {
...this.pdfjsDefaultOptions,
url: urlFile.currentValue,
withCredentials: this.appConfigService.get<boolean>('auth.withCredentials', undefined),
isEvalSupported: false
};
if (this.cacheType) {
pdfOptions.httpHeaders = {
'Cache-Control': this.cacheType
};
}
this.executePdf(pdfOptions);
}
if (!this.urlFile && !this.blobFile) {
throw new Error('Attribute urlFile or blobFile is required');
}
}
executePdf(pdfOptions: Parameters<typeof this.pdfjsLib.getDocument>[0]) {
this.setupPdfJsWorker().then(() => {
this.loadingTask = this.pdfjsLib.getDocument(pdfOptions);
this.loadingTask.onPassword = (callback, reason) => {
this.onPdfPassword(callback, reason);
};
this.loadingTask.onProgress = (progressData: OnProgressParameters) => {
const level = progressData.loaded / progressData.total;
this.loadingPercent = Math.round(level * 100);
};
this.isPanelDisabled = true;
this.loadingTask.promise
.then((pdfDocument) => {
this.totalPages = pdfDocument.numPages;
this.page = 1;
this.displayPage = 1;
this.initPDFViewer(pdfDocument);
return pdfDocument.getPage(1);
})
.then(() => {
setTimeout(() => this.scalePage('init'));
})
.catch(() => this.error.emit());
});
}
private async setupPdfJsWorker(): Promise<void> {
if (this.pdfJsWorkerInstance) {
await this.destroyPfdJsWorker();
} else if (!this.pdfJsWorkerUrl) {
this.pdfJsWorkerUrl = await this.getPdfJsWorker();
}
this.pdfJsWorkerInstance = new Worker(this.pdfJsWorkerUrl, { type: 'module' });
this.pdfjsLib.GlobalWorkerOptions.workerPort = this.pdfJsWorkerInstance;
}
private async getPdfJsWorker(): Promise<string> {
const response = await fetch('./pdf.worker.min.mjs');
const workerScript = await response.text();
const blob = new Blob([workerScript], { type: 'application/javascript' });
return URL.createObjectURL(blob);
}
initPDFViewer(pdfDocument: PDFDocumentProxy) {
const viewer: HTMLDivElement = this.getViewer();
const container = this.getDocumentContainer();
if (viewer && container) {
this.pdfViewer = new this.pdfjsViewer({
container,
viewer,
renderingQueue: this.renderingQueueServices,
eventBus: this.eventBus,
annotationMode: 1
});
// cspell: disable-next
this.eventBus.on('pagechanging', this.onPageChange);
// cspell: disable-next
this.eventBus.on('pagesloaded', this.onPagesLoaded);
// cspell: disable-next
this.eventBus.on('textlayerrendered', () => {
this.onPageRendered();
});
this.eventBus.on('pagerendered', () => {
this.onPageRendered();
});
this.eventBus.on('annotationlayerrendered', (event) =>
this.handleNotRecognizedAnnotations(pdfDocument, event.source.div, event.pageNumber)
);
this.renderingQueueServices.setViewer(this.pdfViewer);
this.pdfViewer.setDocument(pdfDocument);
this.pdfThumbnailsContext.viewer = this.pdfViewer;
}
}
ngOnDestroy() {
if (this.pdfViewer) {
// cspell: disable-next
this.eventBus.off('pagechanging', () => {});
// cspell: disable-next
this.eventBus.off('pagesloaded', () => {});
// cspell: disable-next
this.eventBus.off('textlayerrendered', () => {});
}
if (this.loadingTask) {
this.pdfjsWorkerDestroy$.next(true);
}
this.pdfjsWorkerDestroy$.complete();
this.revokePdfJsWorkerUrl();
}
private async destroyPfdJsWorker() {
if (this.loadingTask.destroy) {
await this.loadingTask.destroy();
}
if (this.pdfJsWorkerInstance) {
this.pdfJsWorkerInstance.terminate();
}
this.loadingTask = null;
}
private revokePdfJsWorkerUrl(): void {
URL.revokeObjectURL(this.pdfJsWorkerUrl);
}
toggleThumbnails() {
this.showThumbnails = !this.showThumbnails;
}
/**
* Method to scale the page current support implementation
*
* @param scaleMode - new scale mode
*/
scalePage(scaleMode: PdfScaleMode) {
this.currentScaleMode = scaleMode;
const viewerContainer = this.getMainContainer();
const documentContainer = this.getDocumentContainer();
if (this.pdfViewer && documentContainer) {
let widthContainer: number;
let heightContainer: number;
if (viewerContainer && viewerContainer.clientWidth <= documentContainer.clientWidth) {
widthContainer = viewerContainer.clientWidth;
heightContainer = viewerContainer.clientHeight;
} else {
widthContainer = documentContainer.clientWidth;
heightContainer = documentContainer.clientHeight;
}
const currentPage = this.pdfViewer._pages[this.pdfViewer._currentPageNumber - 1];
const padding = 20;
const pageWidthScale = ((widthContainer - padding) / currentPage.width) * currentPage.scale;
const pageHeightScale = ((heightContainer - padding) / currentPage.width) * currentPage.scale;
let scale: number;
switch (this.currentScaleMode) {
case 'init':
case 'page-fit': {
scale = this.getUserScaling();
if (!scale) {
scale = this.autoScaling(pageHeightScale, pageWidthScale);
}
break;
}
case 'page-actual': {
scale = 1;
break;
}
case 'page-width': {
scale = pageWidthScale;
break;
}
case 'page-height': {
scale = pageHeightScale;
break;
}
case 'auto': {
scale = this.autoScaling(pageHeightScale, pageWidthScale);
break;
}
default:
return;
}
this.setScaleUpdatePages(scale);
}
}
private autoScaling(pageHeightScale: number, pageWidthScale: number) {
let horizontalScale: number;
if (this.isLandscape) {
horizontalScale = Math.min(pageHeightScale, pageWidthScale);
} else {
horizontalScale = pageWidthScale;
}
horizontalScale = Math.round(horizontalScale);
const scale = Math.min(this.MAX_AUTO_SCALE, horizontalScale);
return this.checkPageFitInContainer(scale);
}
private getMainContainer(): HTMLElement {
return document.getElementById(`${this.randomPdfId}-viewer-main-container`);
}
private getDocumentContainer(): HTMLDivElement {
return document.getElementById(`${this.randomPdfId}-viewer-pdf-viewer`) as HTMLDivElement;
}
private getViewer(): HTMLDivElement {
return document.getElementById(`${this.randomPdfId}-viewer-viewerPdf`) as HTMLDivElement;
}
checkPageFitInContainer(scale: number): number {
const documentContainerSize = this.getDocumentContainer();
const page = this.pdfViewer._pages[this.pdfViewer._currentPageNumber - 1];
if (page.width > documentContainerSize.clientWidth) {
scale = Math.fround((documentContainerSize.clientWidth - 20) / page.width);
if (scale < this.MIN_SCALE) {
scale = this.MIN_SCALE;
}
}
return scale;
}
setDocumentOverflow() {
const documentContainerSize = this.getDocumentContainer();
const page = this.pdfViewer._pages[this.pdfViewer._currentPageNumber - 1];
this.documentOverflow = page.width > documentContainerSize.clientWidth;
}
/**
* Update all the pages with the newScale scale
*
* @param newScale - new scale page
*/
setScaleUpdatePages(newScale: number) {
if (this.pdfViewer) {
if (!this.isSameScale(this.pdfViewer.currentScaleValue, newScale.toString())) {
this.pdfViewer.currentScaleValue = newScale.toString();
}
this.pdfViewer.update();
}
this.setDocumentOverflow();
}
/**
* Check if the request scale of the page is the same for avoid useless re-rendering
*
* @param oldScale - old scale page
* @param newScale - new scale page
* @returns `true` if the scale is the same, otherwise `false`
*/
isSameScale(oldScale: string, newScale: string): boolean {
return newScale === oldScale;
}
/**
* Check if is a land scape view
*
* @param width target width
* @param height target height
* @returns `true` if the target is in the landscape mode, otherwise `false`
*/
isLandscape(width: number, height: number): boolean {
return width > height;
}
/**
* Method triggered when the page is resized
*/
onResize() {
this.scalePage(this.currentScaleMode);
}
/**
* toggle the fit page pdf
*/
pageFit() {
if (this.currentScaleMode !== 'page-fit') {
this.scalePage('page-fit');
} else {
this.scalePage('auto');
}
}
/**
* zoom in page pdf
*
* @param ticks number of ticks to zoom
*/
zoomIn(ticks?: number): void {
let newScale: number = Number(this.pdfViewer.currentScaleValue);
do {
newScale = Number((newScale * this.DEFAULT_SCALE_DELTA).toFixed(2));
newScale = Math.ceil(newScale * 10) / 10;
newScale = Math.min(this.MAX_SCALE, newScale);
} while (--ticks > 0 && newScale < this.MAX_SCALE);
this.currentScaleMode = 'auto';
this.setScaleUpdatePages(newScale);
}
/**
* zoom out page pdf
*
* @param ticks number of ticks to scale
*/
zoomOut(ticks?: number): void {
let newScale: number = Number(this.pdfViewer.currentScaleValue);
do {
newScale = Number((newScale / this.DEFAULT_SCALE_DELTA).toFixed(2));
newScale = Math.floor(newScale * 10) / 10;
newScale = Math.max(this.MIN_SCALE, newScale);
} while (--ticks > 0 && newScale > this.MIN_SCALE);
this.currentScaleMode = 'auto';
this.setScaleUpdatePages(newScale);
}
/**
* load the previous page
*/
previousPage() {
if (this.pdfViewer && this.page > 1) {
this.page--;
this.displayPage = this.page;
this.pdfViewer.currentPageNumber = this.page;
}
}
/**
* load the next page
*/
nextPage() {
if (this.pdfViewer && this.page < this.totalPages) {
this.page++;
this.displayPage = this.page;
this.pdfViewer.currentPageNumber = this.page;
}
}
/**
* load the page in input
*
* @param page to load
*/
inputPage(page: string) {
const pageInput = parseInt(page, 10);
if (!isNaN(pageInput) && pageInput > 0 && pageInput <= this.totalPages) {
this.page = pageInput;
this.displayPage = this.page;
this.pdfViewer.currentPageNumber = this.page;
} else {
this.displayPage = this.page;
}
}
/**
* Page Change Event
*
* @param event - page change event
* @param event.pageNumber - the new page number
* @param event.source - the source object
* @param event.source.container - the container element
* @param event.source.container.id - the container id
*/
onPageChange(event: PageChangingEvent) {
if (event.source?.container.id === `${this.randomPdfId}-viewer-pdf-viewer`) {
this.page = event.pageNumber;
this.displayPage = event.pageNumber;
}
}
onPdfPassword(callback, reason) {
this.dialog
.open(PdfPasswordDialogComponent, {
width: '400px',
data: { reason }
})
.afterClosed()
.subscribe((password) => {
if (password) {
callback(password);
} else {
this.close.emit();
}
});
}
/**
* Page Rendered Event
*/
onPageRendered() {
this.rendered.emit();
}
/**
* Pages Loaded Event
*
*/
onPagesLoaded() {
this.isPanelDisabled = false;
this.pagesLoaded.emit();
}
/**
* Keyboard Event Listener
*
* @param event KeyboardEvent
*/
@HostListener('document:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
const key = event.keyCode;
if (key === 39) {
// right arrow
this.nextPage();
} else if (key === 37) {
// left arrow
this.previousPage();
}
}
private async handleNotRecognizedAnnotations(
pdfDocument: PDFDocumentProxy,
documentContainer: HTMLDivElement,
pageNumber: number
): Promise<void> {
const page = await pdfDocument.getPage(pageNumber);
const annotations = await page.getAnnotations();
annotations.forEach((annotation) => {
if (annotation.subtype !== 'Text' || annotation.name !== 'NoIcon') {
return;
}
const annotationElement = documentContainer.querySelector<HTMLElement>(`[data-annotation-id="${annotation.id}"]`);
if (!annotationElement) {
return;
}
this.correctAnnotationImage(annotationElement);
const text: string = annotation.contentsObj?.str?.trim();
if (!text || (annotation.popupRef && documentContainer.querySelector(`[data-annotation-id="${annotation.popupRef}"]`))) {
return;
}
this.createAnnotationPopup(annotation, text, annotationElement);
});
}
private correctAnnotationImage(annotationElement: HTMLElement): void {
const annotationImageElement = annotationElement.querySelector('img');
if (annotationImageElement) {
annotationImageElement.src =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGV' +
'pZ2h0PSIyNCI+PHBhdGggZD0iTTIgMmgxNHYxNEgyeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0zIDNoMTJ2MTJIM3oiIGZpbGw9Ii' +
'NmZmRiMDAiLz48cGF0aCBkPSJNNSA1aDh2OGgtOHoiIGZpbGw9IiNmZmJiMDAiLz48L3N2Zz4=';
annotationImageElement.alt = this.translateService.instant('ADF_VIEWER.ARIA.NOTE_ANNOTATION_IMG');
}
}
private createAnnotationPopup(annotation: PdfAnnotationData, text: string, annotationElement: HTMLElement): void {
const popupElement = document.createElement('div');
let headerElement: HTMLSpanElement;
if (annotation.titleObj?.str) {
headerElement = this.createAnnotationPopupHeader(annotation as PdfAnnotationWithTitle);
}
const contentElement = this.createAnnotationPopupContent(text);
popupElement.classList.add('popup', 'adf-pdf-viewer-annotation-tooltip');
headerElement ? popupElement.append(headerElement, contentElement) : popupElement.append(contentElement);
annotationElement.appendChild(popupElement);
}
private createAnnotationPopupHeader(annotation: PdfAnnotationWithTitle): HTMLSpanElement {
const headerElement = document.createElement('span');
const titleElement = document.createElement('span');
let dateElement: HTMLTimeElement;
titleElement.innerText = annotation.titleObj.str;
titleElement.classList.add('title');
headerElement.classList.add('header');
if (annotation.modificationDate) {
dateElement = document.createElement('time');
dateElement.innerText = PDFDateString.toDateObject(annotation.modificationDate).toLocaleString();
dateElement.classList.add('popupDate');
headerElement.append(titleElement, dateElement);
} else {
headerElement.append(titleElement);
}
return headerElement;
}
private createAnnotationPopupContent(text: string): HTMLSpanElement {
const contentElement = document.createElement('span');
contentElement.innerText = text;
contentElement.classList.add('popupContent');
return contentElement;
}
}
@@ -0,0 +1,25 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Provider } from '@angular/core';
import { PDF_VIEWER_COMPONENT } from '@alfresco/adf-core';
import { PdfViewerComponent } from './components/pdf-viewer/pdf-viewer.component';
/** @returns Provider that registers the PdfViewerComponent for the viewer */
export function providePdfViewer(): Provider {
return { provide: PDF_VIEWER_COMPONENT, useValue: PdfViewerComponent };
}
@@ -0,0 +1,228 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import type { PDFViewer } from 'pdfjs-dist/types/web/pdf_viewer';
import type { PDFThumbnailViewer } from 'pdfjs-dist/types/web/pdf_thumbnail_viewer';
import type { PDFPageView } from 'pdfjs-dist/types/web/pdf_page_view';
interface VisiblePage {
id: number;
}
interface VisiblePages {
first?: VisiblePage;
last?: VisiblePage;
views?: Array<{ view: PDFPageView }>;
}
/**
* Type helper for accessing the resume method on PDFPageView.
* PDFPageView implements IRenderableView which includes a resume method,
* but the TypeScript definitions don't properly expose it.
*/
// cspell:ignore Renderable
interface ResumableView {
resume?: () => void;
}
/**
*
* RenderingQueueServices rendering of the views for pages and thumbnails.
*
*/
@Injectable()
export class RenderingQueueServices {
renderingStates = {
INITIAL: 0,
RUNNING: 1,
PAUSED: 2,
FINISHED: 3
};
CLEANUP_TIMEOUT: number = 30_000;
pdfViewer: PDFViewer | null = null;
pdfThumbnailViewer: PDFThumbnailViewer | null = null;
onIdle: (() => void) | null = null;
highestPriorityPage: string | null = null;
idleTimeout: number | null = null;
printing = false;
isThumbnailViewEnabled = false;
/**
* Set the instance of the PDF Viewer
*
* @param pdfViewer viewer instance
*/
setViewer(pdfViewer: PDFViewer): void {
this.pdfViewer = pdfViewer;
}
/**
* Sets the instance of the PDF Thumbnail Viewer
*
* @param pdfThumbnailViewer viewer instance
*/
setThumbnailViewer(pdfThumbnailViewer: PDFThumbnailViewer): void {
this.pdfThumbnailViewer = pdfThumbnailViewer;
}
/**
* Check if the view has highest rendering priority
*
* @param view view to render
* @returns `true` if the view has higher priority, otherwise `false`
*/
isHighestPriority(view: PDFPageView): boolean {
return this.highestPriorityPage === view.renderingId;
}
renderHighestPriority(currentlyVisiblePages?: unknown): void {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
this.idleTimeout = null;
}
// Pages have a higher priority than thumbnails, so check them first.
if (this.pdfViewer?.forceRendering(currentlyVisiblePages)) {
return;
}
// No pages needed rendering so check thumbnails.
if (this.pdfThumbnailViewer && this.isThumbnailViewEnabled && this.pdfThumbnailViewer.forceRendering()) {
return;
}
if (this.printing) {
// If printing is currently ongoing do not reschedule cleanup.
return;
}
if (this.onIdle) {
// Type assertion needed: setTimeout returns NodeJS.Timeout in Node types,
// but returns number at runtime in browser (where this code executes).
// PDFRenderingQueue interface requires idleTimeout to be number.
this.idleTimeout = setTimeout(this.onIdle.bind(this), this.CLEANUP_TIMEOUT) as unknown as number;
}
}
/**
* Gets the highest priority page to render from the visible pages
* This method is part of the PDFRenderingQueue interface compatibility
*
* @param visible visible pages information
* @param views array of page views
* @param scrolledDown whether the user scrolled down
* @returns the highest priority page view to render, null if all done, or false if no visible pages
*/
getHighestPriority(visible: VisiblePages, views: PDFPageView[], scrolledDown: boolean): PDFPageView | null | false {
// The state has changed figure out which page has the highest priority to
// render next (if any).
// Priority:
// 1 visible pages
// 2 if last scrolled down page after the visible pages
// 2 if last scrolled up page before the visible pages
const visibleViews = visible.views;
if (!visibleViews) {
return false;
}
const numberVisible = visibleViews.length;
if (numberVisible === 0) {
return false;
}
for (let i = 0; i < numberVisible; ++i) {
const view = visibleViews[i].view;
if (!this.isViewFinished(view)) {
return view;
}
}
// All the visible views have rendered, try to render next/previous pages.
if (scrolledDown && visible.last) {
const nextPageIndex = visible.last.id;
// ID's start at 1 so no need to add 1.
if (views[nextPageIndex] && !this.isViewFinished(views[nextPageIndex])) {
return views[nextPageIndex];
}
} else if (visible.first) {
const previousPageIndex = visible.first.id - 2;
if (views[previousPageIndex] && !this.isViewFinished(views[previousPageIndex])) {
return views[previousPageIndex];
}
}
// Everything that needs to be rendered has been.
return null;
}
hasViewer(): boolean {
return !!this.pdfViewer;
}
/**
* Checks if the view rendering is finished
*
* @param view the View instance to check
* @returns `true` if rendering is finished, otherwise `false`
*/
isViewFinished(view: PDFPageView): boolean {
return view.renderingState === this.renderingStates.FINISHED;
}
/**
* Render a page or thumbnail view. This calls the appropriate function
* based on the views state. If the view is already rendered it will return
* false.
*
* @param view View instance to render
* @returns the rendered state of the view
*/
renderView(view: PDFPageView): boolean {
const state = view.renderingState;
switch (state) {
case this.renderingStates.FINISHED: {
return false;
}
case this.renderingStates.PAUSED: {
this.highestPriorityPage = view.renderingId;
const resumableView = view as unknown as ResumableView;
if (resumableView.resume) {
resumableView.resume();
}
break;
}
case this.renderingStates.RUNNING: {
this.highestPriorityPage = view.renderingId;
break;
}
case this.renderingStates.INITIAL: {
this.highestPriorityPage = view.renderingId;
const continueRendering = () => {
this.renderHighestPriority();
};
view.draw().then(continueRendering, continueRendering);
break;
}
default: {
break;
}
}
return true;
}
}