diff --git a/config/config.example.yml b/config/config.example.yml
index c3dc673b537..845565388c4 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -471,6 +471,7 @@ bundle:
mediaViewer:
image: false
video: false
+ pdf: false
# Whether the end user agreement is required before users use the repository.
# If enabled, the user will be required to accept the agreement before they can use the repository.
diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.html b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.html
new file mode 100644
index 00000000000..6dc687f2fc9
--- /dev/null
+++ b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ {{ pdfs?.length || 0 }}
+
+
+
+
+ @for (item of pdfs; track $index) {
+
+ {{ dsoNameService.getName(item.bitstream) }}
+
+ }
+
+
+
+
+ @if (isLoading) {
+
+
+ Loading...
+
+
{{ 'media-viewer.loading' | translate }}...
+
+ }
+
+ @else {
+
+
+ {{ 'media-viewer.pdf.browser-not-support' | translate }}
+ {{ 'media-viewer.pdf.download' | translate }} .
+
+
+ }
+
+
+
\ No newline at end of file
diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.scss b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.scss
new file mode 100644
index 00000000000..121516400d0
--- /dev/null
+++ b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.scss
@@ -0,0 +1,24 @@
+.pdf-viewer-container {
+ margin-bottom: 2rem;
+}
+
+.simple-view-element-header {
+ font-size: 1.25rem;
+}
+
+.pdf-viewer {
+ height: 50vh;
+ padding-bottom: 1px;
+ min-height: 500px;
+ display: flex;
+ flex-direction: column;
+}
+
+.loading-overlay {
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: #f8f9fa;
+ color: #6c757d;
+}
\ No newline at end of file
diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.spec.ts b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.spec.ts
new file mode 100644
index 00000000000..448f89b2de4
--- /dev/null
+++ b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.spec.ts
@@ -0,0 +1,176 @@
+import {
+ provideHttpClient,
+ withInterceptorsFromDi,
+} from '@angular/common/http';
+import {
+ HttpTestingController,
+ provideHttpClientTesting,
+} from '@angular/common/http/testing';
+import {
+ ChangeDetectorRef,
+ NO_ERRORS_SCHEMA,
+} from '@angular/core';
+import {
+ ComponentFixture,
+ TestBed,
+ waitForAsync,
+} from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import {
+ By,
+ DomSanitizer,
+} from '@angular/platform-browser';
+import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service';
+import { Bitstream } from '@dspace/core/shared/bitstream.model';
+import { MediaViewerItem } from '@dspace/core/shared/media-viewer-item.model';
+import { MockBitstreamFormat1 } from '@dspace/core/testing/item.mock';
+import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock';
+import {
+ TranslateLoader,
+ TranslateModule,
+} from '@ngx-translate/core';
+import { of } from 'rxjs';
+
+import { MediaViewerPdfComponent } from './media-viewer-pdf.component';
+
+describe('MediaViewerPdfComponent', () => {
+ let component: MediaViewerPdfComponent;
+ let fixture: ComponentFixture;
+ let httpMock: HttpTestingController;
+ let sanitizer: DomSanitizer;
+
+ const mockBitstream: Bitstream = Object.assign(new Bitstream(), {
+ sizeBytes: 1024,
+ format: of(MockBitstreamFormat1),
+ _links: {
+ self: {
+ href: 'https://demo.dspace.org/api/core/bitstreams/123',
+ },
+ content: {
+ href: 'https://demo.dspace.org/api/core/bitstreams/123/content',
+ },
+ },
+ id: '123',
+ uuid: '123',
+ type: 'bitstream',
+ metadata: {
+ 'dc.title': [
+ { language: null, value: 'test_pdf.pdf' },
+ ],
+ },
+ });
+
+ const mockMediaViewerItems: MediaViewerItem[] = [
+ { bitstream: mockBitstream, format: 'application', mimetype: 'application/pdf', thumbnail: null, accessToken: null },
+ { bitstream: mockBitstream, format: 'application', mimetype: 'application/pdf', thumbnail: null, accessToken: null },
+ ];
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ FormsModule,
+ TranslateModule.forRoot({
+ loader: { provide: TranslateLoader, useClass: TranslateLoaderMock },
+ }),
+ MediaViewerPdfComponent,
+ ],
+ providers: [
+ DSONameService,
+ ChangeDetectorRef,
+ provideHttpClient(withInterceptorsFromDi()),
+ provideHttpClientTesting(),
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MediaViewerPdfComponent);
+ component = fixture.componentInstance;
+ sanitizer = TestBed.inject(DomSanitizer);
+ httpMock = TestBed.inject(HttpTestingController);
+ component.pdfs = mockMediaViewerItems;
+ fixture.detectChanges();
+ const initReq = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content');
+ initReq.flush(new Blob(['initial content'], { type: 'application/pdf' }));
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('ngOnInit', () => {
+ it('should call loadPdf with index 0', () => {
+ spyOn(component, 'loadPdf');
+ component.ngOnInit();
+ // Dot notation cannot be used because 'loadPdf' is a private method.
+ // Accessing the private method only for testing purposes.
+ // eslint-disable-next-line @typescript-eslint/dot-notation
+ expect(component['loadPdf']).toHaveBeenCalledWith(0);
+ });
+ });
+
+ describe('loadPdf', () => {
+ it('should load and set blobUrl on success', () => {
+ const mockBlob = new Blob(['PDF content'], { type: 'application/pdf' });
+ const bypassSpy = spyOn(sanitizer, 'bypassSecurityTrustResourceUrl').and.callThrough();
+
+ component.ngOnInit();
+ const req = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content');
+ expect(req.request.method).toBe('GET');
+ req.flush(mockBlob);
+
+ expect(component.isLoading).toBeFalse();
+ expect(bypassSpy).toHaveBeenCalled();
+ expect(component.blobUrl).toBeTruthy();
+ });
+
+ it('should handle errors gracefully', () => {
+ spyOn(console, 'error');
+ component.ngOnInit();
+ const req = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content');
+ req.error(new ProgressEvent('Network error'));
+
+ expect(component.isLoading).toBeFalse();
+ expect(console.error).toHaveBeenCalled();
+ });
+ });
+
+ describe('selectedMedia', () => {
+ it('should set currentIndex and load the selected pdf', () => {
+ const spyLoad = spyOn(component, 'loadPdf');
+ component.selectedMedia(1);
+ expect(component.currentIndex).toBe(1);
+ expect(spyLoad).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('UI template', () => {
+ it('should display the select element with the correct number of options', () => {
+ fixture.detectChanges();
+ const selectEl = fixture.debugElement.query(By.css('select'));
+ expect(selectEl).toBeTruthy();
+
+ const options = selectEl.nativeElement.querySelectorAll('option');
+ expect(options.length).toBe(2);
+ });
+
+ it('should show loading overlay when isLoading is true', () => {
+ component.isLoading = true;
+ fixture.detectChanges();
+ const loadingEl = fixture.debugElement.query(By.css('.loading-overlay'));
+ expect(loadingEl).toBeTruthy();
+ });
+
+ it('should show pdf object when not loading', () => {
+ component.isLoading = false;
+ fixture.detectChanges();
+ const objectEl = fixture.debugElement.query(By.css('object'));
+ expect(objectEl).toBeTruthy();
+ });
+ });
+});
diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.ts b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.ts
new file mode 100644
index 00000000000..54c7ea2b9e9
--- /dev/null
+++ b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.ts
@@ -0,0 +1,68 @@
+import { HttpClient } from '@angular/common/http';
+import {
+ ChangeDetectorRef,
+ Component,
+ Input,
+ OnInit,
+ ViewChild,
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import {
+ DomSanitizer,
+ SafeResourceUrl,
+} from '@angular/platform-browser';
+import { TranslateModule } from '@ngx-translate/core';
+
+import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
+import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
+
+@Component({
+ selector: 'ds-base-media-viewer-pdf',
+ templateUrl: './media-viewer-pdf.component.html',
+ styleUrls: ['./media-viewer-pdf.component.scss'],
+ imports: [
+ FormsModule,
+ TranslateModule,
+ ],
+})
+export class MediaViewerPdfComponent implements OnInit {
+ @Input() pdfs: MediaViewerItem[];
+ @ViewChild('pdfViewer') pdfViewer;
+
+ blobUrl: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl('');
+ currentIndex = 0;
+
+ isLoading = false;
+
+ constructor(private http: HttpClient, private sanitizer: DomSanitizer, public dsoNameService: DSONameService, private cdr: ChangeDetectorRef) { }
+
+ ngOnInit() {
+ this.loadPdf(this.currentIndex);
+ }
+
+ selectedMedia(index: number) {
+ this.currentIndex = index;
+ this.loadPdf(index);
+ }
+
+ private loadPdf(index: number) {
+ this.isLoading = true;
+
+ const url = this.pdfs[index].bitstream._links.content.href;
+
+ this.http.get(url, { responseType: 'blob' }).subscribe({
+ next: (blob) => {
+ const blobUrl = URL.createObjectURL(blob);
+ this.blobUrl = this.sanitizer.bypassSecurityTrustResourceUrl(blobUrl);
+
+ this.isLoading = false;
+ this.cdr.detectChanges();
+ },
+ error: (err: unknown) => {
+ console.error('Error loading PDF:', err);
+ this.isLoading = false;
+ this.cdr.detectChanges();
+ },
+ });
+ }
+}
diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/themed-media-viewer-pdf.component.ts b/src/app/item-page/media-viewer/media-viewer-pdf/themed-media-viewer-pdf.component.ts
new file mode 100644
index 00000000000..1abfe09f599
--- /dev/null
+++ b/src/app/item-page/media-viewer/media-viewer-pdf/themed-media-viewer-pdf.component.ts
@@ -0,0 +1,38 @@
+import {
+ Component,
+ Input,
+} from '@angular/core';
+
+import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
+import { ThemedComponent } from '../../../shared/theme-support/themed.component';
+import { MediaViewerPdfComponent } from './media-viewer-pdf.component';
+
+/**
+ * Themed wrapper for {@link MediaViewerPdfComponent}.
+ */
+@Component({
+ selector: 'ds-media-viewer-pdf',
+ styleUrls: [],
+ templateUrl: '../../../shared/theme-support/themed.component.html',
+})
+export class ThemedMediaViewerPdfComponent extends ThemedComponent {
+
+ @Input() pdfs: MediaViewerItem[];
+
+ protected inAndOutputNames: (keyof MediaViewerPdfComponent & keyof this)[] = [
+ 'pdfs',
+ ];
+
+ protected getComponentName(): string {
+ return 'MediaViewerPdfComponent';
+ }
+
+ protected importThemedComponent(themeName: string): Promise {
+ return import(`../../../../themes/${themeName}/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component`);
+ }
+
+ protected importUnthemedComponent(): Promise {
+ return import('./media-viewer-pdf.component');
+ }
+
+}
diff --git a/src/app/item-page/media-viewer/media-viewer.component.html b/src/app/item-page/media-viewer/media-viewer.component.html
index 0629ded8ca3..265d9c5536c 100644
--- a/src/app/item-page/media-viewer/media-viewer.component.html
+++ b/src/app/item-page/media-viewer/media-viewer.component.html
@@ -1,42 +1,42 @@
@if (isLoading) {
-
+
}
@else {
-
}
-
+
\ No newline at end of file
diff --git a/src/app/item-page/media-viewer/media-viewer.component.spec.ts b/src/app/item-page/media-viewer/media-viewer.component.spec.ts
index c003429c536..992151c55e6 100644
--- a/src/app/item-page/media-viewer/media-viewer.component.spec.ts
+++ b/src/app/item-page/media-viewer/media-viewer.component.spec.ts
@@ -132,6 +132,7 @@ describe('MediaViewerComponent', () => {
comp.mediaOptions = {
image: true,
video: true,
+ pdf: true,
};
comp.isLoading = true;
fixture.detectChanges();
@@ -159,6 +160,7 @@ describe('MediaViewerComponent', () => {
comp.mediaOptions = {
image: true,
video: true,
+ pdf: true,
};
comp.isLoading = false;
fixture.detectChanges();
diff --git a/src/app/item-page/media-viewer/media-viewer.component.ts b/src/app/item-page/media-viewer/media-viewer.component.ts
index 11b1e6793bc..a22fe5129e1 100644
--- a/src/app/item-page/media-viewer/media-viewer.component.ts
+++ b/src/app/item-page/media-viewer/media-viewer.component.ts
@@ -35,6 +35,7 @@ import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.comp
import { VarDirective } from '../../shared/utils/var.directive';
import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component';
import { ThemedMediaViewerImageComponent } from './media-viewer-image/themed-media-viewer-image.component';
+import { ThemedMediaViewerPdfComponent } from './media-viewer-pdf/themed-media-viewer-pdf.component';
import { ThemedMediaViewerVideoComponent } from './media-viewer-video/themed-media-viewer-video.component';
/**
@@ -48,6 +49,7 @@ import { ThemedMediaViewerVideoComponent } from './media-viewer-video/themed-med
AsyncPipe,
ThemedLoadingComponent,
ThemedMediaViewerImageComponent,
+ ThemedMediaViewerPdfComponent,
ThemedMediaViewerVideoComponent,
ThemedThumbnailComponent,
TranslateModule,
@@ -88,10 +90,12 @@ export class MediaViewerComponent implements OnDestroy, OnInit {
* This method loads all the Bitstreams and Thumbnails and converts it to {@link MediaViewerItem}s
*/
ngOnInit(): void {
+ console.log('MediaViewerComponent init', this.mediaList$);
this.itemRequest = this.route.snapshot.data.itemRequest;
const types: string[] = [
...(this.mediaOptions.image ? ['image'] : []),
...(this.mediaOptions.video ? ['audio', 'video'] : []),
+ ...(this.mediaOptions.pdf ? ['application/pdf'] : []),
];
this.thumbnailsRD$ = this.loadRemoteData('THUMBNAIL');
this.subs.push(this.loadRemoteData('ORIGINAL').subscribe((bitstreamsRD: RemoteData>) => {
@@ -113,7 +117,7 @@ export class MediaViewerComponent implements OnDestroy, OnInit {
format,
thumbnailsRD.payload && thumbnailsRD.payload.page[index],
);
- if (types.includes(mediaItem.format)) {
+ if (types.includes(mediaItem.mimetype)) {
this.mediaList$.next([...this.mediaList$.getValue(), mediaItem]);
} else if (format.mimetype === 'text/vtt') {
this.captions$.next([...this.captions$.getValue(), bitstreamsRD.payload.page[index]]);
@@ -180,4 +184,9 @@ export class MediaViewerComponent implements OnDestroy, OnInit {
return null;
}
+ filterPdf(mediaList: MediaViewerItem[]) {
+ const pdfs = mediaList.filter(item => item.mimetype === 'application/pdf');
+ return pdfs;
+ }
+
}
diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html
index a294cc025bc..83989b4a85b 100644
--- a/src/app/item-page/simple/item-types/publication/publication.component.html
+++ b/src/app/item-page/simple/item-types/publication/publication.component.html
@@ -24,11 +24,6 @@
}
- @if (mediaViewer.image || mediaViewer.video) {
-
-
-
- }
+ @if (mediaViewer.image || mediaViewer.video || mediaViewer.pdf) {
+
+
+
+ }
@if (geospatialItemPageFieldsEnabled) {
}
- @if (mediaViewer.image || mediaViewer.video) {
-
-
-
- }
+ @if (mediaViewer.image || mediaViewer.video || mediaViewer.pdf) {
+
+
+
+ }
@if (geospatialItemPageFieldsEnabled) {