From 9e9e55758f3fba01f4b3147399a4b596f6454a8e Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:24:43 -0800 Subject: [PATCH] Enhancement: pngx pdf viewer (#12043) --- src-ui/angular.json | 1 - .../document-detail/document-detail.spec.ts | 2 +- src-ui/jest.config.js | 4 + src-ui/package.json | 2 +- src-ui/pnpm-lock.yaml | 157 +++++++-- src-ui/setup-jest.ts | 40 ++- .../admin/settings/settings.component.html | 4 +- .../admin/settings/settings.component.ts | 4 +- .../pdf-editor/pdf-editor.component.html | 4 +- .../pdf-editor/pdf-editor.component.scss | 4 +- .../common/pdf-editor/pdf-editor.component.ts | 11 +- .../pdf-viewer/pdf-viewer.component.html | 3 + .../pdf-viewer/pdf-viewer.component.scss | 153 +++++++++ .../pdf-viewer/pdf-viewer.component.spec.ts | 299 ++++++++++++++++++ .../common/pdf-viewer/pdf-viewer.component.ts | 266 ++++++++++++++++ .../common/pdf-viewer/pdf-viewer.types.ts | 25 ++ .../preview-popup.component.html | 12 +- .../preview-popup.component.spec.ts | 23 +- .../preview-popup/preview-popup.component.ts | 22 +- .../document-detail.component.html | 14 +- .../document-detail.component.scss | 9 +- .../document-detail.component.spec.ts | 23 +- .../document-detail.component.ts | 83 ++--- .../document-detail/zoom-setting.ts | 11 - src-ui/src/app/data/ui-settings.ts | 4 +- src-ui/src/main.ts | 2 - .../src/test/mocks/pdfjs-legacy-build-pdf.ts | 24 ++ src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts | 79 +++++ src-ui/tsconfig.spec.json | 3 +- 29 files changed, 1123 insertions(+), 165 deletions(-) create mode 100644 src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.html create mode 100644 src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.scss create mode 100644 src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.spec.ts create mode 100644 src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.ts create mode 100644 src-ui/src/app/components/common/pdf-viewer/pdf-viewer.types.ts delete mode 100644 src-ui/src/app/components/document-detail/zoom-setting.ts create mode 100644 src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts create mode 100644 src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts diff --git a/src-ui/angular.json b/src-ui/angular.json index 9d6039789..9ed7488b6 100644 --- a/src-ui/angular.json +++ b/src-ui/angular.json @@ -86,7 +86,6 @@ ], "scripts": [], "allowedCommonJsDependencies": [ - "ng2-pdf-viewer", "file-saver", "utif" ], diff --git a/src-ui/e2e/document-detail/document-detail.spec.ts b/src-ui/e2e/document-detail/document-detail.spec.ts index 8e0967188..ba10745ec 100644 --- a/src-ui/e2e/document-detail/document-detail.spec.ts +++ b/src-ui/e2e/document-detail/document-detail.spec.ts @@ -72,7 +72,7 @@ test('should show a mobile preview', async ({ page }) => { await page.setViewportSize({ width: 400, height: 1000 }) await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible() await page.getByRole('tab', { name: 'Preview' }).click() - await page.waitForSelector('pdf-viewer') + await page.waitForSelector('pngx-pdf-viewer') }) test('should show a list of notes', async ({ page }) => { diff --git a/src-ui/jest.config.js b/src-ui/jest.config.js index 03461c92e..7b06016dd 100644 --- a/src-ui/jest.config.js +++ b/src-ui/jest.config.js @@ -31,6 +31,10 @@ module.exports = { moduleNameMapper: { ...esmPreset.moduleNameMapper, '^src/(.*)': '/src/$1', + '^pdfjs-dist/legacy/build/pdf\\.mjs$': + '/src/test/mocks/pdfjs-legacy-build-pdf.ts', + '^pdfjs-dist/web/pdf_viewer\\.mjs$': + '/src/test/mocks/pdfjs-web-pdf_viewer.ts', }, workerIdleMemoryLimit: '512MB', reporters: [ diff --git a/src-ui/package.json b/src-ui/package.json index f37f8cc8d..e671eb61c 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -27,12 +27,12 @@ "bootstrap": "^5.3.8", "file-saver": "^2.0.5", "mime-names": "^1.0.0", - "ng2-pdf-viewer": "^10.4.0", "ngx-bootstrap-icons": "^1.9.3", "ngx-color": "^10.1.0", "ngx-cookie-service": "^21.1.0", "ngx-device-detector": "^11.0.0", "ngx-ui-tour-ng-bootstrap": "^18.0.0", + "pdfjs-dist": "^5.4.624", "rxjs": "^7.8.2", "tslib": "^2.8.1", "utif": "^3.1.0", diff --git a/src-ui/pnpm-lock.yaml b/src-ui/pnpm-lock.yaml index 8645e2f75..f09f7b366 100644 --- a/src-ui/pnpm-lock.yaml +++ b/src-ui/pnpm-lock.yaml @@ -56,9 +56,6 @@ importers: mime-names: specifier: ^1.0.0 version: 1.0.0 - ng2-pdf-viewer: - specifier: ^10.4.0 - version: 10.4.0 ngx-bootstrap-icons: specifier: ^1.9.3 version: 1.9.3(@angular/common@21.1.3(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0)) @@ -74,6 +71,9 @@ importers: ngx-ui-tour-ng-bootstrap: specifier: ^18.0.0 version: 18.0.0(2a89effa12f6df8cde064aa7713e7e29) + pdfjs-dist: + specifier: ^5.4.624 + version: 5.4.624 rxjs: specifier: ^7.8.2 version: 7.8.2 @@ -2130,6 +2130,76 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/canvas-android-arm64@0.1.90': + resolution: {integrity: sha512-3JBULVF+BIgr7yy7Rf8UjfbkfFx4CtXrkJFD1MDgKJ83b56o0U9ciT8ZGTCNmwWkzu8RbNKlyqPP3KYRG88y7Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.90': + resolution: {integrity: sha512-L8XVTXl+8vd8u7nPqcX77NyG5RuFdVsJapQrKV9WE3jBayq1aSMht/IH7Dwiz/RNJ86E5ZSg9pyUPFIlx52PZA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.90': + resolution: {integrity: sha512-h0ukhlnGhacbn798VWYTQZpf6JPDzQYaow+vtQ2Fat7j7ImDdpg6tfeqvOTO1r8wS+s+VhBIFITC7aA1Aik0ZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.90': + resolution: {integrity: sha512-JCvTl99b/RfdBtgftqrf+5UNF7GIbp7c5YBFZ+Bd6++4Y3phaXG/4vD9ZcF1bw1P4VpALagHmxvodHuQ9/TfTg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.90': + resolution: {integrity: sha512-vbWFp8lrP8NIM5L4zNOwnsqKIkJo0+GIRUDcLFV9XEJCptCc1FY6/tM02PT7GN4PBgochUPB1nBHdji6q3ieyQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.90': + resolution: {integrity: sha512-8Bc0BgGEeOaux4EfIfNzcRRw0JE+lO9v6RWQFCJNM9dJFE4QJffTf88hnmbOaI6TEMpgWOKipbha3dpIdUqb/g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.90': + resolution: {integrity: sha512-0iiVDG5IH+gJb/YUrY/pRdbsjcgvwUmeckL/0gShWAA7004ygX2ST69M1wcfyxXrzFYjdF8S/Sn6aCAeBi89XQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.90': + resolution: {integrity: sha512-SkKmlHMvA5spXuKfh7p6TsScDf7lp5XlMbiUhjdCtWdOS6Qke/A4qGVOciy6piIUCJibL+YX+IgdGqzm2Mpx/w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.90': + resolution: {integrity: sha512-o6QgS10gAS4vvELGDOOWYfmERXtkVRYFWBCjomILWfMgCvBVutn8M97fsMW5CrEuJI8YuxuJ7U+/DQ9oG93vDA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.90': + resolution: {integrity: sha512-2UHO/DC1oyuSjeCAhHA0bTD9qsg58kknRqjJqRfvIEFtdqdtNTcWXMCT9rQCuJ8Yx5ldhyh2SSp7+UDqD2tXZQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.90': + resolution: {integrity: sha512-48CxEbzua5BP4+OumSZdi3+9fNiRO8cGNBlO2bKwx1PoyD1R2AXzPtqd/no1f1uSl0W2+ihOO1v3pqT3USbmgQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.90': + resolution: {integrity: sha512-vO9j7TfwF9qYCoTOPO39yPLreTRslBVOaeIwhDZkizDvBb0MounnTl0yeWUMBxP4Pnkg9Sv+3eQwpxNUmTwt0w==} + engines: {node: '>= 10'} + '@napi-rs/nice-android-arm-eabi@1.1.1': resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} @@ -5015,9 +5085,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - ng2-pdf-viewer@10.4.0: - resolution: {integrity: sha512-TPh1oLZoeARggreTG60Sl3ikSn+Z3+At9pLZ0o/vxPjc7mW2ok2XPyl2Oqz7VyP80ipVorldm1hsLPBmNe2zzA==} - ngx-bootstrap-icons@1.9.3: resolution: {integrity: sha512-UsFqJ/cn0u5W39hVMIDbm+ze1dCF9fDV839scqeimi70Efcmg41zOx6GgR6i2gWAVFR0OBso1cdqb4E75XhTSw==} engines: {node: '>= 16.18.1', npm: '>= 8.11.0'} @@ -5084,6 +5151,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-readable-to-web-readable-stream@0.4.2: + resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -5279,13 +5349,9 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - path2d@0.2.2: - resolution: {integrity: sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==} - engines: {node: '>=6'} - - pdfjs-dist@4.8.69: - resolution: {integrity: sha512-IHZsA4T7YElCKNNXtiLgqScw4zPd3pG9do8UrznC757gMd7UPeHSL2qwNNMJo4r79fl8oj1Xx+1nh2YkzdMpLQ==} - engines: {node: '>=18'} + pdfjs-dist@5.4.624: + resolution: {integrity: sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==} + engines: {node: '>=20.16.0 || >=22.3.0'} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -8796,6 +8862,54 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@napi-rs/canvas-android-arm64@0.1.90': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.90': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.90': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.90': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.90': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.90': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.90': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.90': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.90': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.90': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.90': + optional: true + + '@napi-rs/canvas@0.1.90': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.90 + '@napi-rs/canvas-darwin-arm64': 0.1.90 + '@napi-rs/canvas-darwin-x64': 0.1.90 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.90 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.90 + '@napi-rs/canvas-linux-arm64-musl': 0.1.90 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.90 + '@napi-rs/canvas-linux-x64-gnu': 0.1.90 + '@napi-rs/canvas-linux-x64-musl': 0.1.90 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.90 + '@napi-rs/canvas-win32-x64-msvc': 0.1.90 + optional: true + '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -11975,11 +12089,6 @@ snapshots: neo-async@2.6.2: {} - ng2-pdf-viewer@10.4.0: - dependencies: - pdfjs-dist: 4.8.69 - tslib: 2.8.1 - ngx-bootstrap-icons@1.9.3(@angular/common@21.1.3(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0)): dependencies: '@angular/common': 21.1.3(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2) @@ -12060,6 +12169,9 @@ snapshots: node-int64@0.4.0: {} + node-readable-to-web-readable-stream@0.4.2: + optional: true + node-releases@2.0.27: {} nopt@9.0.0: @@ -12291,13 +12403,10 @@ snapshots: path-to-regexp@8.3.0: {} - path2d@0.2.2: - optional: true - - pdfjs-dist@4.8.69: + pdfjs-dist@5.4.624: optionalDependencies: - canvas: 3.0.0 - path2d: 0.2.2 + '@napi-rs/canvas': 0.1.90 + node-readable-to-web-readable-stream: 0.4.2 picocolors@1.1.1: {} diff --git a/src-ui/setup-jest.ts b/src-ui/setup-jest.ts index a6461d350..86e447b59 100644 --- a/src-ui/setup-jest.ts +++ b/src-ui/setup-jest.ts @@ -100,10 +100,10 @@ const mock = () => { } } -Object.defineProperty(window, 'open', { value: jest.fn() }) -Object.defineProperty(window, 'localStorage', { value: mock() }) -Object.defineProperty(window, 'sessionStorage', { value: mock() }) -Object.defineProperty(window, 'getComputedStyle', { +Object.defineProperty(globalThis, 'open', { value: jest.fn() }) +Object.defineProperty(globalThis, 'localStorage', { value: mock() }) +Object.defineProperty(globalThis, 'sessionStorage', { value: mock() }) +Object.defineProperty(globalThis, 'getComputedStyle', { value: () => ['-webkit-appearance'], }) Object.defineProperty(navigator, 'clipboard', { @@ -115,13 +115,33 @@ Object.defineProperty(navigator, 'canShare', { value: () => true }) if (!navigator.share) { Object.defineProperty(navigator, 'share', { value: jest.fn() }) } -if (!URL.createObjectURL) { - Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() }) +if (!globalThis.URL.createObjectURL) { + Object.defineProperty(globalThis.URL, 'createObjectURL', { value: jest.fn() }) } -if (!URL.revokeObjectURL) { - Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() }) +if (!globalThis.URL.revokeObjectURL) { + Object.defineProperty(globalThis.URL, 'revokeObjectURL', { value: jest.fn() }) } -Object.defineProperty(window, 'ResizeObserver', { value: mock() }) +class MockResizeObserver { + private readonly callback: ResizeObserverCallback + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + + observe = jest.fn() + unobserve = jest.fn() + disconnect = jest.fn() + + trigger = (entries: ResizeObserverEntry[] = []) => { + this.callback(entries, this) + } +} + +Object.defineProperty(globalThis, 'ResizeObserver', { + writable: true, + configurable: true, + value: MockResizeObserver, +}) if (typeof IntersectionObserver === 'undefined') { class MockIntersectionObserver { @@ -136,7 +156,7 @@ if (typeof IntersectionObserver === 'undefined') { takeRecords = jest.fn() } - Object.defineProperty(window, 'IntersectionObserver', { + Object.defineProperty(globalThis, 'IntersectionObserver', { writable: true, configurable: true, value: MockIntersectionObserver, diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index caec63e30..d07ee94a3 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -222,8 +222,8 @@

Only applies to the Paperless-ngx PDF viewer.

diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index a2cfae819..f548b71f4 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -65,8 +65,8 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss import { SelectComponent } from '../../common/input/select/select.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode' +import { PdfZoomScale } from '../../common/pdf-viewer/pdf-viewer.types' import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component' -import { ZoomSetting } from '../../document-detail/zoom-setting' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' enum SettingsNavIDs { @@ -196,7 +196,7 @@ export class SettingsComponent public readonly GlobalSearchType = GlobalSearchType - public readonly ZoomSetting = ZoomSetting + public readonly PdfZoomScale = PdfZoomScale public readonly PdfEditorEditMode = PdfEditorEditMode diff --git a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.html b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.html index 2d1d01ca7..e73e1fc71 100644 --- a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.html +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.html @@ -1,4 +1,4 @@ - + } - + } @placeholder {
diff --git a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.scss b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.scss index c2e29463b..e9eba0fc2 100644 --- a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.scss +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.scss @@ -15,13 +15,13 @@ background-color: gray; height: 240px; - pdf-viewer { + pngx-pdf-viewer { width: 100%; height: 100%; } } -::ng-deep .ng2-pdf-viewer-container { +::ng-deep .pngx-pdf-viewer-container { overflow: hidden; } diff --git a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts index c25e215e5..c294516e0 100644 --- a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts @@ -6,12 +6,16 @@ import { import { Component, inject } from '@angular/core' import { FormsModule } from '@angular/forms' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' -import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { DocumentService } from 'src/app/services/rest/document.service' import { SettingsService } from 'src/app/services/settings.service' import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' +import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component' +import { + PdfRenderMode, + PngxPdfDocumentProxy, +} from '../pdf-viewer/pdf-viewer.types' import { PdfEditorEditMode } from './pdf-editor-edit-mode' interface PageOperation { @@ -29,11 +33,12 @@ interface PageOperation { imports: [ DragDropModule, FormsModule, - PdfViewerModule, NgxBootstrapIconsModule, + PngxPdfViewerComponent, ], }) export class PDFEditorComponent extends ConfirmDialogComponent { + PdfRenderMode = PdfRenderMode public PdfEditorEditMode = PdfEditorEditMode private documentService = inject(DocumentService) @@ -53,7 +58,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent { return this.documentService.getPreviewUrl(this.documentID) } - pdfLoaded(pdf: PDFDocumentProxy) { + pdfLoaded(pdf: PngxPdfDocumentProxy) { this.totalPages = pdf.numPages this.pages = Array.from({ length: this.totalPages }, (_, i) => ({ page: i + 1, diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.html b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.html new file mode 100644 index 000000000..edc8efa8a --- /dev/null +++ b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.html @@ -0,0 +1,3 @@ +
+
+
diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.scss b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.scss new file mode 100644 index 000000000..eac316f15 --- /dev/null +++ b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.scss @@ -0,0 +1,153 @@ +:host { + display: block; + width: 100%; + height: 100%; + position: relative; +} + +:host ::ng-deep .pngx-pdf-viewer-container { + position: absolute; + inset: 0; + overflow: auto; +} + +:host ::ng-deep .pdfViewer { + --scale-factor: 1; + --page-bg-color: unset; + padding-bottom: 0; +} + +:host ::ng-deep .pdfViewer .page { + --user-unit: 1; + --total-scale-factor: calc(var(--scale-factor) * var(--user-unit)); + --scale-round-x: 1px; + --scale-round-y: 1px; + direction: ltr; + margin: 0 auto 10px; + border: 0; + position: relative; + overflow: visible; + background-clip: content-box; + background-color: var(--page-bg-color, rgb(255 255 255)); +} + +:host ::ng-deep .pdfViewer > .page:last-of-type { + margin-bottom: 0; +} + +:host ::ng-deep .pdfViewer.singlePageView { + display: inline-block; +} + +:host ::ng-deep .pdfViewer.singlePageView .page { + margin: 0; + border: none; +} + +:host ::ng-deep .pdfViewer .canvasWrapper { + overflow: hidden; + width: 100%; + height: 100%; +} + +:host ::ng-deep .pdfViewer .canvasWrapper canvas { + position: absolute; + top: 0; + left: 0; + margin: 0; + display: block; + width: 100%; + height: 100%; + contain: content; +} + +:host ::ng-deep .textLayer { + position: absolute; + text-align: initial; + inset: 0; + overflow: clip; + opacity: 1; + line-height: 1; + text-size-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + z-index: 0; + user-select: text; + --min-font-size: 1; + --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); + --min-font-size-inv: calc(1 / var(--min-font-size)); +} + +:host ::ng-deep .textLayer.highlighting { + touch-action: none; +} + +:host ::ng-deep .textLayer :is(span, br) { + position: absolute; + white-space: pre; + color: transparent; + cursor: text; + transform-origin: 0% 0%; +} + +:host ::ng-deep .textLayer > :not(.markedContent), +:host ::ng-deep .textLayer .markedContent span:not(.markedContent) { + z-index: 1; + --font-height: 0; + font-size: calc(var(--text-scale-factor) * var(--font-height)); + --scale-x: 1; + --rotate: 0deg; + transform: rotate(var(--rotate)) scaleX(var(--scale-x)) + scale(var(--min-font-size-inv)); +} + +:host ::ng-deep .textLayer .markedContent { + display: contents; +} + +:host ::ng-deep .textLayer span[role='img'] { + user-select: none; + cursor: default; +} + +:host ::ng-deep .textLayer .highlight { + --highlight-bg-color: rgb(180 0 170 / 0.25); + --highlight-selected-bg-color: rgb(0 100 0 / 0.25); + --highlight-backdrop-filter: none; + --highlight-selected-backdrop-filter: none; + margin: -1px; + padding: 1px; + background-color: var(--highlight-bg-color); + backdrop-filter: var(--highlight-backdrop-filter); + border-radius: 4px; +} + +:host ::ng-deep .appended:is(.textLayer .highlight) { + position: initial; +} + +:host ::ng-deep .begin:is(.textLayer .highlight) { + border-radius: 4px 0 0 4px; +} + +:host ::ng-deep .end:is(.textLayer .highlight) { + border-radius: 0 4px 4px 0; +} + +:host ::ng-deep .middle:is(.textLayer .highlight) { + border-radius: 0; +} + +:host ::ng-deep .selected:is(.textLayer .highlight) { + background-color: var(--highlight-selected-bg-color); +} + +:host ::ng-deep .textLayer ::selection { + background: rgba(30, 100, 255, 0.35); +} + +:host ::ng-deep .annotationLayer { + position: absolute; + inset: 0; + pointer-events: none; +} diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.spec.ts b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.spec.ts new file mode 100644 index 000000000..4703e857d --- /dev/null +++ b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.spec.ts @@ -0,0 +1,299 @@ +import { SimpleChange } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs' +import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs' +import { PngxPdfViewerComponent } from './pdf-viewer.component' +import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types' + +describe('PngxPdfViewerComponent', () => { + let fixture: ComponentFixture + let component: PngxPdfViewerComponent + + const initComponent = async (src = 'test.pdf') => { + component.src = src + fixture.detectChanges() + await fixture.whenStable() + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PngxPdfViewerComponent], + }).compileComponents() + + fixture = TestBed.createComponent(PngxPdfViewerComponent) + component = fixture.componentInstance + }) + + it('loads a document and emits events', async () => { + const loadSpy = jest.fn() + const renderedSpy = jest.fn() + component.afterLoadComplete.subscribe(loadSpy) + component.rendered.subscribe(renderedSpy) + + await initComponent() + + expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe( + '/assets/js/pdf.worker.min.mjs' + ) + const isVisible = (component as any).findController.onIsPageVisible as + | (() => boolean) + | undefined + expect(isVisible?.()).toBe(true) + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ numPages: 1 }) + ) + expect(renderedSpy).toHaveBeenCalled() + expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer) + }) + + it('initializes single-page viewer and disables text layer', async () => { + component.renderMode = PdfRenderMode.Single + component.selectable = false + + await initComponent() + + const viewer = (component as any).pdfViewer as PDFSinglePageViewer & { + options: Record + } + expect(viewer).toBeInstanceOf(PDFSinglePageViewer) + expect(viewer.options.textLayerMode).toBe(0) + }) + + it('applies zoom, rotation, and page changes', async () => { + await initComponent() + + const pageSpy = jest.fn() + component.pageChange.subscribe(pageSpy) + + component.zoomScale = PdfZoomScale.PageFit + component.zoom = PdfZoomLevel.Two + component.rotation = 90 + component.page = 2 + + component.ngOnChanges({ + zoomScale: new SimpleChange( + PdfZoomScale.PageWidth, + PdfZoomScale.PageFit, + false + ), + zoom: new SimpleChange(PdfZoomLevel.One, PdfZoomLevel.Two, false), + rotation: new SimpleChange(undefined, 90, false), + page: new SimpleChange(undefined, 2, false), + }) + + const viewer = (component as any).pdfViewer as PDFViewer + expect(viewer.pagesRotation).toBe(90) + expect(viewer.currentPageNumber).toBe(2) + expect(pageSpy).toHaveBeenCalledWith(2) + + viewer.currentScale = 1 + ;(component as any).applyScale() + expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit) + expect(viewer.currentScale).toBe(2) + + const applyScaleSpy = jest.spyOn(component as any, 'applyScale') + component.page = 2 + ;(component as any).lastViewerPage = 2 + ;(component as any).applyViewerState() + expect((component as any).lastViewerPage).toBeUndefined() + expect(applyScaleSpy).toHaveBeenCalled() + }) + + it('dispatches find when search query changes after render', async () => { + await initComponent() + + const eventBus = (component as any).eventBus as { dispatch: jest.Mock } + const dispatchSpy = jest.spyOn(eventBus, 'dispatch') + + ;(component as any).hasRenderedPage = true + component.searchQuery = 'needle' + component.ngOnChanges({ + searchQuery: new SimpleChange('', 'needle', false), + }) + + expect(dispatchSpy).toHaveBeenCalledWith('find', { + query: 'needle', + caseSensitive: false, + highlightAll: true, + phraseSearch: true, + }) + + component.ngOnChanges({ + searchQuery: new SimpleChange('needle', 'needle', false), + }) + expect(dispatchSpy).toHaveBeenCalledTimes(1) + }) + + it('emits error when document load fails', async () => { + const errorSpy = jest.fn() + component.loadError.subscribe(errorSpy) + + jest.spyOn(pdfjs, 'getDocument').mockImplementationOnce(() => { + return { + promise: Promise.reject(new Error('boom')), + destroy: jest.fn(), + } as any + }) + + await initComponent('bad.pdf') + + expect(errorSpy).toHaveBeenCalled() + }) + + it('cleans up resources on destroy', async () => { + await initComponent() + + const viewer = (component as any).pdfViewer as { cleanup: jest.Mock } + const loadingTask = (component as any).loadingTask as unknown as { + destroy: jest.Mock + } + const resizeObserver = (component as any).resizeObserver as unknown as { + disconnect: jest.Mock + } + const eventBus = (component as any).eventBus as { off: jest.Mock } + + jest.spyOn(viewer, 'cleanup') + jest.spyOn(loadingTask, 'destroy') + jest.spyOn(resizeObserver, 'disconnect') + jest.spyOn(eventBus, 'off') + + component.ngOnDestroy() + + expect(eventBus.off).toHaveBeenCalledWith( + 'pagerendered', + expect.any(Function) + ) + expect(eventBus.off).toHaveBeenCalledWith('pagesinit', expect.any(Function)) + expect(eventBus.off).toHaveBeenCalledWith( + 'pagechanging', + expect.any(Function) + ) + expect(resizeObserver.disconnect).toHaveBeenCalled() + expect(loadingTask.destroy).toHaveBeenCalled() + expect(viewer.cleanup).toHaveBeenCalled() + expect((component as any).pdfViewer).toBeUndefined() + }) + + it('skips work when viewer is missing or has no pages', () => { + const eventBus = (component as any).eventBus as { dispatch: jest.Mock } + const dispatchSpy = jest.spyOn(eventBus, 'dispatch') + ;(component as any).dispatchFindIfReady() + expect(dispatchSpy).not.toHaveBeenCalled() + ;(component as any).applyViewerState() + ;(component as any).applyScale() + + const viewer = new PDFViewer({ eventBus: undefined }) + viewer.pagesCount = 0 + ;(component as any).pdfViewer = viewer + viewer.currentScale = 5 + ;(component as any).applyScale() + expect(viewer.currentScale).toBe(5) + }) + + it('returns early on src change in ngOnChanges', () => { + const loadSpy = jest.spyOn(component as any, 'loadDocument') + const initSpy = jest.spyOn(component as any, 'initViewer') + const scaleSpy = jest.spyOn(component as any, 'applyViewerState') + const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver') + + component.ngOnChanges({ + src: new SimpleChange(undefined, 'test.pdf', true), + zoomScale: new SimpleChange( + PdfZoomScale.PageWidth, + PdfZoomScale.PageFit, + false + ), + }) + + expect(loadSpy).toHaveBeenCalled() + expect(resizeSpy).not.toHaveBeenCalled() + expect(initSpy).not.toHaveBeenCalled() + expect(scaleSpy).not.toHaveBeenCalled() + }) + + it('applies viewer state after view init when already loaded', () => { + const applySpy = jest.spyOn(component as any, 'applyViewerState') + ;(component as any).hasLoaded = true + ;(component as any).pdf = { numPages: 1 } + + fixture.detectChanges() + + expect(applySpy).toHaveBeenCalled() + }) + + it('skips viewer state after view init when no pdf is available', () => { + const applySpy = jest.spyOn(component as any, 'applyViewerState') + ;(component as any).hasLoaded = true + + fixture.detectChanges() + + expect(applySpy).not.toHaveBeenCalled() + }) + + it('does not reload when already loaded', async () => { + await initComponent() + + const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument') + const callCount = getDocumentSpy.mock.calls.length + await (component as any).loadDocument() + + expect(getDocumentSpy).toHaveBeenCalledTimes(callCount) + }) + + it('runs applyScale on resize observer notifications', async () => { + await initComponent() + + const applySpy = jest.spyOn(component as any, 'applyScale') + const resizeObserver = (component as any).resizeObserver as { + trigger: () => void + } + resizeObserver.trigger() + + expect(applySpy).toHaveBeenCalled() + }) + + it('skips page work when no pages are available', async () => { + await initComponent() + + const viewer = (component as any).pdfViewer as PDFViewer + viewer.pagesCount = 0 + const applyScaleSpy = jest.spyOn(component as any, 'applyScale') + + component.page = undefined + ;(component as any).lastViewerPage = 1 + ;(component as any).applyViewerState() + + expect(applyScaleSpy).not.toHaveBeenCalled() + expect((component as any).lastViewerPage).toBe(1) + }) + + it('falls back to a default zoom when input is invalid', async () => { + await initComponent() + + const viewer = (component as any).pdfViewer as PDFViewer + viewer.currentScale = 3 + component.zoom = 'not-a-number' as PdfZoomLevel + ;(component as any).applyScale() + + expect(viewer.currentScale).toBe(3) + }) + + it('re-initializes viewer on selectable or render mode changes', async () => { + await initComponent() + + const initSpy = jest.spyOn(component as any, 'initViewer') + component.selectable = false + component.renderMode = PdfRenderMode.Single + + component.ngOnChanges({ + selectable: new SimpleChange(true, false, false), + renderMode: new SimpleChange( + PdfRenderMode.All, + PdfRenderMode.Single, + false + ), + }) + + expect(initSpy).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.ts b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.ts new file mode 100644 index 000000000..4a7008b7b --- /dev/null +++ b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.ts @@ -0,0 +1,266 @@ +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + SimpleChanges, + ViewChild, +} from '@angular/core' +import { + getDocument, + GlobalWorkerOptions, + PDFDocumentLoadingTask, + PDFDocumentProxy, +} from 'pdfjs-dist/legacy/build/pdf.mjs' +import { + EventBus, + PDFFindController, + PDFLinkService, + PDFSinglePageViewer, + PDFViewer, +} from 'pdfjs-dist/web/pdf_viewer.mjs' +import { + PdfRenderMode, + PdfSource, + PdfZoomLevel, + PdfZoomScale, + PngxPdfDocumentProxy, +} from './pdf-viewer.types' + +@Component({ + selector: 'pngx-pdf-viewer', + templateUrl: './pdf-viewer.component.html', + styleUrl: './pdf-viewer.component.scss', +}) +export class PngxPdfViewerComponent + implements AfterViewInit, OnChanges, OnDestroy +{ + @Input() src!: PdfSource + @Input() page?: number + @Output() pageChange = new EventEmitter() + @Input() rotation?: number + @Input() renderMode: PdfRenderMode = PdfRenderMode.All + @Input() selectable = true + @Input() searchQuery = '' + @Input() zoom: PdfZoomLevel = PdfZoomLevel.One + @Input() zoomScale: PdfZoomScale = PdfZoomScale.PageWidth + + @Output() afterLoadComplete = new EventEmitter() + @Output() rendered = new EventEmitter() + @Output() loadError = new EventEmitter() + + @ViewChild('container', { static: true }) + private readonly container!: ElementRef + + @ViewChild('viewer', { static: true }) + private readonly viewer!: ElementRef + + private hasLoaded = false + private loadingTask?: PDFDocumentLoadingTask + private resizeObserver?: ResizeObserver + private pdf?: PDFDocumentProxy + private pdfViewer?: PDFViewer | PDFSinglePageViewer + private hasRenderedPage = false + private lastFindQuery = '' + private lastViewerPage?: number + + private readonly eventBus = new EventBus() + private readonly linkService = new PDFLinkService({ eventBus: this.eventBus }) + private readonly findController = new PDFFindController({ + eventBus: this.eventBus, + linkService: this.linkService, + updateMatchesCountOnProgress: false, + }) + + private readonly onPageRendered = () => { + this.hasRenderedPage = true + this.dispatchFindIfReady() + this.rendered.emit() + } + private readonly onPagesInit = () => this.applyScale() + private readonly onPageChanging = (evt: { pageNumber: number }) => { + // Avoid [(page)] two-way binding re-triggers navigation + this.lastViewerPage = evt.pageNumber + this.pageChange.emit(evt.pageNumber) + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['src']) { + this.hasLoaded = false + this.loadDocument() + return + } + + if (changes['zoomScale']) { + this.setupResizeObserver() + } + + if (changes['selectable'] || changes['renderMode']) { + this.initViewer() + } + + if ( + changes['page'] || + changes['zoom'] || + changes['zoomScale'] || + changes['rotation'] + ) { + this.applyViewerState() + } + + if (changes['searchQuery']) { + this.dispatchFindIfReady() + } + } + + ngAfterViewInit(): void { + this.setupResizeObserver() + this.initViewer() + if (!this.hasLoaded) { + this.loadDocument() + return + } + if (this.pdf) { + this.applyViewerState() + } + } + + ngOnDestroy(): void { + this.eventBus.off('pagerendered', this.onPageRendered) + this.eventBus.off('pagesinit', this.onPagesInit) + this.eventBus.off('pagechanging', this.onPageChanging) + this.resizeObserver?.disconnect() + this.loadingTask?.destroy() + this.pdfViewer?.cleanup() + this.pdfViewer = undefined + } + + private async loadDocument(): Promise { + if (this.hasLoaded) { + return + } + + this.hasLoaded = true + this.hasRenderedPage = false + this.lastFindQuery = '' + this.loadingTask?.destroy() + + GlobalWorkerOptions.workerSrc = '/assets/js/pdf.worker.min.mjs' + this.loadingTask = getDocument(this.src) + + try { + const pdf = await this.loadingTask.promise + this.pdf = pdf + this.linkService.setDocument(pdf) + this.findController.onIsPageVisible = () => true + this.pdfViewer?.setDocument(pdf) + this.applyViewerState() + this.afterLoadComplete.emit(pdf) + } catch (err) { + this.loadError.emit(err) + } + } + + private setupResizeObserver(): void { + this.resizeObserver?.disconnect() + this.resizeObserver = new ResizeObserver(() => { + this.applyScale() + }) + this.resizeObserver.observe(this.container.nativeElement) + } + + private initViewer(): void { + this.viewer.nativeElement.innerHTML = '' + this.pdfViewer?.cleanup() + this.hasRenderedPage = false + this.lastFindQuery = '' + + const textLayerMode = this.selectable === false ? 0 : 1 + const options = { + container: this.container.nativeElement, + viewer: this.viewer.nativeElement, + eventBus: this.eventBus, + linkService: this.linkService, + findController: this.findController, + textLayerMode, + removePageBorders: true, + } + + this.pdfViewer = + this.renderMode === PdfRenderMode.Single + ? new PDFSinglePageViewer(options) + : new PDFViewer(options) + this.linkService.setViewer(this.pdfViewer) + + this.eventBus.off('pagerendered', this.onPageRendered) + this.eventBus.off('pagesinit', this.onPagesInit) + this.eventBus.off('pagechanging', this.onPageChanging) + this.eventBus.on('pagerendered', this.onPageRendered) + this.eventBus.on('pagesinit', this.onPagesInit) + this.eventBus.on('pagechanging', this.onPageChanging) + + if (this.pdf) { + this.pdfViewer.setDocument(this.pdf) + this.applyViewerState() + } + } + + private applyViewerState(): void { + if (!this.pdfViewer) { + return + } + const hasPages = this.pdfViewer.pagesCount > 0 + if (typeof this.rotation === 'number' && hasPages) { + this.pdfViewer.pagesRotation = this.rotation + } + if ( + typeof this.page === 'number' && + hasPages && + this.page !== this.lastViewerPage + ) { + this.pdfViewer.currentPageNumber = this.page + } + if (this.page === this.lastViewerPage) { + this.lastViewerPage = undefined + } + if (hasPages) { + this.applyScale() + } + this.dispatchFindIfReady() + } + + private applyScale(): void { + if (!this.pdfViewer) { + return + } + if (this.pdfViewer.pagesCount === 0) { + return + } + const zoomFactor = Number(this.zoom) || 1 + this.pdfViewer.currentScaleValue = this.zoomScale + if (zoomFactor !== 1) { + this.pdfViewer.currentScale = this.pdfViewer.currentScale * zoomFactor + } + } + + private dispatchFindIfReady(): void { + if (!this.hasRenderedPage) { + return + } + const query = this.searchQuery.trim() + if (query === this.lastFindQuery) { + return + } + this.lastFindQuery = query + this.eventBus.dispatch('find', { + query, + caseSensitive: false, + highlightAll: query.length > 0, + phraseSearch: true, + }) + } +} diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.types.ts b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.types.ts new file mode 100644 index 000000000..edce9a7e7 --- /dev/null +++ b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.types.ts @@ -0,0 +1,25 @@ +export type PngxPdfDocumentProxy = { + numPages: number +} + +export type PdfSource = string | { url: string; password?: string } + +export enum PdfRenderMode { + Single = 'single', + All = 'all', +} + +export enum PdfZoomScale { + PageFit = 'page-fit', + PageWidth = 'page-width', +} + +export enum PdfZoomLevel { + Quarter = '.25', + Half = '.5', + ThreeQuarters = '.75', + One = '1', + OneAndHalf = '1.5', + Two = '2', + Three = '3', +} diff --git a/src-ui/src/app/components/common/preview-popup/preview-popup.component.html b/src-ui/src/app/components/common/preview-popup/preview-popup.component.html index 287a659ef..e949b7b0b 100644 --- a/src-ui/src/app/components/common/preview-popup/preview-popup.component.html +++ b/src-ui/src/app/components/common/preview-popup/preview-popup.component.html @@ -23,14 +23,12 @@
} @if (!requiresPassword) { - - + [renderMode]="PdfRenderMode.All" + [searchQuery]="documentService.searchQuery" + (loadError)="onError($event)"> + } } } diff --git a/src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts b/src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts index db2c0d3de..643464ee8 100644 --- a/src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts +++ b/src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts @@ -12,6 +12,7 @@ import { of, throwError } from 'rxjs' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { DocumentService } from 'src/app/services/rest/document.service' import { SettingsService } from 'src/app/services/settings.service' +import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component' import { PreviewPopupComponent } from './preview-popup.component' const doc = { @@ -78,7 +79,7 @@ describe('PreviewPopupComponent', () => { component.popover.open() fixture.detectChanges() expect(fixture.debugElement.query(By.css('object'))).toBeNull() - expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull() + expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull() }) it('should show lock icon on password error', () => { @@ -159,23 +160,15 @@ describe('PreviewPopupComponent', () => { expect(component.popover.isOpen()).toBeFalsy() }) - it('should dispatch find event on viewer loaded if searchQuery set', () => { + it('should pass searchQuery to viewer', () => { documentService.searchQuery = 'test' settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) component.popover.open() - jest.advanceTimersByTime(1000) fixture.detectChanges() - // normally setup by pdf-viewer - jest.replaceProperty(component.pdfViewer, 'eventBus', { - dispatch: jest.fn(), - } as any) - const dispatchSpy = jest.spyOn(component.pdfViewer.eventBus, 'dispatch') - component.onPageRendered() - expect(dispatchSpy).toHaveBeenCalledWith('find', { - query: 'test', - caseSensitive: false, - highlightAll: true, - phraseSearch: true, - }) + const viewer = fixture.debugElement.query( + By.directive(PngxPdfViewerComponent) + ) + expect(viewer).not.toBeNull() + expect(viewer.componentInstance.searchQuery).toBe('test') }) }) diff --git a/src-ui/src/app/components/common/preview-popup/preview-popup.component.ts b/src-ui/src/app/components/common/preview-popup/preview-popup.component.ts index 4528dff8a..9c42d1a61 100644 --- a/src-ui/src/app/components/common/preview-popup/preview-popup.component.ts +++ b/src-ui/src/app/components/common/preview-popup/preview-popup.component.ts @@ -1,7 +1,6 @@ import { HttpClient } from '@angular/common/http' import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core' import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap' -import { PdfViewerComponent, PdfViewerModule } from 'ng2-pdf-viewer' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { first, Subject, takeUntil } from 'rxjs' import { Document } from 'src/app/data/document' @@ -10,6 +9,8 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe' import { DocumentService } from 'src/app/services/rest/document.service' import { SettingsService } from 'src/app/services/settings.service' +import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component' +import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types' @Component({ selector: 'pngx-preview-popup', @@ -18,14 +19,15 @@ import { SettingsService } from 'src/app/services/settings.service' imports: [ NgbPopoverModule, DocumentTitlePipe, - PdfViewerModule, + PngxPdfViewerComponent, SafeUrlPipe, NgxBootstrapIconsModule, ], }) export class PreviewPopupComponent implements OnDestroy { + PdfRenderMode = PdfRenderMode private settingsService = inject(SettingsService) - private documentService = inject(DocumentService) + public readonly documentService = inject(DocumentService) private http = inject(HttpClient) private _document: Document @@ -61,8 +63,6 @@ export class PreviewPopupComponent implements OnDestroy { @ViewChild('popover') popover: NgbPopover - @ViewChild('pdfViewer') pdfViewer: PdfViewerComponent - mouseOnPreview: boolean = false popoverClass: string = 'shadow popover-preview' @@ -114,18 +114,6 @@ export class PreviewPopupComponent implements OnDestroy { } } - onPageRendered() { - // Only triggered by the pngx pdf viewer - if (this.documentService.searchQuery) { - this.pdfViewer.eventBus.dispatch('find', { - query: this.documentService.searchQuery, - caseSensitive: false, - highlightAll: true, - phraseSearch: true, - }) - } - } - mouseEnterPreview() { this.mouseOnPreview = true if (!this.popover.isOpen()) { diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 306152cc4..c2b545b09 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -456,17 +456,15 @@ @case (ContentRenderType.PDF) { @if (!useNativePdfViewer) {
- - + (loadError)="onError($event)" + (afterLoadComplete)="pdfPreviewLoaded($event)"> +
} @else { diff --git a/src-ui/src/app/components/document-detail/document-detail.component.scss b/src-ui/src/app/components/document-detail/document-detail.component.scss index 3fc009020..3986f2cbc 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.scss +++ b/src-ui/src/app/components/document-detail/document-detail.component.scss @@ -5,20 +5,15 @@ } .pdf-viewer-container { - padding-top: 10px; + padding: 8px; background-color: gray; - pdf-viewer { + pngx-pdf-viewer { width: 100%; height: 100%; } } -::ng-deep .ng2-pdf-viewer-container .page { - --page-margin: 0 auto 10px; - --page-border: 0; -} - .btn-group .dropdown-toggle-split { border-top-right-radius: inherit; border-bottom-right-radius: inherit; diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 7be9d9151..4f299e26a 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -69,8 +69,11 @@ import { environment } from 'src/environments/environment' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' +import { + PdfZoomLevel, + PdfZoomScale, +} from '../common/pdf-viewer/pdf-viewer.types' import { DocumentDetailComponent } from './document-detail.component' -import { ZoomSetting } from './zoom-setting' const doc: Document = { id: 3, @@ -860,7 +863,7 @@ describe('DocumentDetailComponent', () => { it('should support zoom controls', () => { initNormally() - component.setZoom(ZoomSetting.One) // from select + component.setZoom(PdfZoomLevel.One) // from select expect(component.previewZoomSetting).toEqual('1') component.increaseZoom() expect(component.previewZoomSetting).toEqual('1.5') @@ -868,18 +871,18 @@ describe('DocumentDetailComponent', () => { expect(component.previewZoomSetting).toEqual('2') component.decreaseZoom() expect(component.previewZoomSetting).toEqual('1.5') - component.setZoom(ZoomSetting.One) // from select + component.setZoom(PdfZoomLevel.One) // from select component.decreaseZoom() expect(component.previewZoomSetting).toEqual('.75') - component.setZoom(ZoomSetting.PageFit) // from select + component.setZoom(PdfZoomScale.PageFit) // from select expect(component.previewZoomScale).toEqual('page-fit') expect(component.previewZoomSetting).toEqual('1') component.increaseZoom() expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomScale).toEqual('page-width') - component.setZoom(ZoomSetting.PageFit) // from select + component.setZoom(PdfZoomScale.PageFit) // from select expect(component.previewZoomScale).toEqual('page-fit') expect(component.previewZoomSetting).toEqual('1') component.decreaseZoom() @@ -889,10 +892,10 @@ describe('DocumentDetailComponent', () => { it('should select correct zoom setting in dropdown', () => { initNormally() - component.setZoom(ZoomSetting.PageFit) - expect(component.currentZoom).toEqual(ZoomSetting.PageFit) - component.setZoom(ZoomSetting.Quarter) - expect(component.currentZoom).toEqual(ZoomSetting.Quarter) + component.setZoom(PdfZoomScale.PageFit) + expect(component.currentZoom).toEqual(PdfZoomScale.PageFit) + component.setZoom(PdfZoomLevel.Quarter) + expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter) }) it('should support updating notes dynamically', () => { @@ -1017,7 +1020,7 @@ describe('DocumentDetailComponent', () => { settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) expect(component.useNativePdfViewer).toBeFalsy() fixture.detectChanges() - expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull() + expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull() }) it('should display native pdf viewer if enabled', () => { diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 016a94d71..349d3d199 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -18,7 +18,6 @@ import { NgbNavModule, } from '@ng-bootstrap/ng-bootstrap' import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms' -import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { DeviceDetectorService } from 'ngx-device-detector' import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs' @@ -108,13 +107,19 @@ import { UrlComponent } from '../common/input/url/url.component' import { PageHeaderComponent } from '../common/page-header/page-header.component' import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode' import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component' +import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component' +import { + PdfRenderMode, + PdfZoomLevel, + PdfZoomScale, + PngxPdfDocumentProxy, +} from '../common/pdf-viewer/pdf-viewer.types' import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component' import { DocumentHistoryComponent } from '../document-history/document-history.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component' -import { ZoomSetting } from './zoom-setting' enum DocumentDetailNavIDs { Details = 1, @@ -168,16 +173,17 @@ enum ContentRenderType { NgbNavModule, NgbDropdownModule, NgxBootstrapIconsModule, - PdfViewerModule, TextAreaComponent, RouterModule, + PngxPdfViewerComponent, ], }) export class DocumentDetailComponent extends ComponentWithPermissions implements OnInit, OnDestroy, DirtyComponent { - private documentsService = inject(DocumentService) + PdfRenderMode = PdfRenderMode + documentsService = inject(DocumentService) private route = inject(ActivatedRoute) private tagService = inject(TagService) private correspondentService = inject(CorrespondentService) @@ -246,8 +252,8 @@ export class DocumentDetailComponent previewCurrentPage: number = 1 previewNumPages: number - previewZoomSetting: ZoomSetting = ZoomSetting.One - previewZoomScale: ZoomSetting = ZoomSetting.PageWidth + previewZoomSetting: PdfZoomLevel = PdfZoomLevel.One + previewZoomScale: PdfZoomScale = PdfZoomScale.PageWidth store: BehaviorSubject isDirty$: Observable @@ -503,7 +509,9 @@ export class DocumentDetailComponent } ngOnInit(): void { - this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING)) + this.setZoom( + this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale + ) this.documentForm.valueChanges .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe((values) => { @@ -1204,7 +1212,7 @@ export class DocumentDetailComponent }) } - pdfPreviewLoaded(pdf: PDFDocumentProxy) { + pdfPreviewLoaded(pdf: PngxPdfDocumentProxy) { this.previewNumPages = pdf.numPages if (this.password) this.requiresPassword = false setTimeout(() => { @@ -1225,31 +1233,33 @@ export class DocumentDetailComponent } } - setZoom(setting: ZoomSetting) { - if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) { + setZoom(setting: PdfZoomScale | PdfZoomLevel) { + if ( + setting === PdfZoomScale.PageFit || + setting === PdfZoomScale.PageWidth + ) { this.previewZoomScale = setting - this.previewZoomSetting = ZoomSetting.One - } else { - this.previewZoomSetting = setting - this.previewZoomScale = ZoomSetting.PageWidth + this.previewZoomSetting = PdfZoomLevel.One + return } + this.previewZoomSetting = setting + this.previewZoomScale = PdfZoomScale.PageWidth } get zoomSettings() { - return Object.values(ZoomSetting).filter( - (setting) => setting !== ZoomSetting.PageWidth - ) + return [PdfZoomScale.PageFit, ...Object.values(PdfZoomLevel)] } get currentZoom() { - if (this.previewZoomScale === ZoomSetting.PageFit) { - return ZoomSetting.PageFit - } else return this.previewZoomSetting + if (this.previewZoomScale === PdfZoomScale.PageFit) { + return PdfZoomScale.PageFit + } + return this.previewZoomSetting } - getZoomSettingTitle(setting: ZoomSetting): string { + getZoomSettingTitle(setting: PdfZoomScale | PdfZoomLevel): string { switch (setting) { - case ZoomSetting.PageFit: + case PdfZoomScale.PageFit: return $localize`Page Fit` default: return `${parseFloat(setting) * 100}%` @@ -1257,25 +1267,24 @@ export class DocumentDetailComponent } increaseZoom(): void { - let currentIndex = Object.values(ZoomSetting).indexOf( - this.previewZoomSetting - ) - if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 5 - this.previewZoomScale = ZoomSetting.PageWidth + const zoomLevels = Object.values(PdfZoomLevel) + let currentIndex = zoomLevels.indexOf(this.previewZoomSetting) + if (this.previewZoomScale === PdfZoomScale.PageFit) { + currentIndex = zoomLevels.indexOf(PdfZoomLevel.One) + } + this.previewZoomScale = PdfZoomScale.PageWidth this.previewZoomSetting = - Object.values(ZoomSetting)[ - Math.min(Object.values(ZoomSetting).length - 1, currentIndex + 1) - ] + zoomLevels[Math.min(zoomLevels.length - 1, currentIndex + 1)] } decreaseZoom(): void { - let currentIndex = Object.values(ZoomSetting).indexOf( - this.previewZoomSetting - ) - if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 4 - this.previewZoomScale = ZoomSetting.PageWidth - this.previewZoomSetting = - Object.values(ZoomSetting)[Math.max(2, currentIndex - 1)] + const zoomLevels = Object.values(PdfZoomLevel) + let currentIndex = zoomLevels.indexOf(this.previewZoomSetting) + if (this.previewZoomScale === PdfZoomScale.PageFit) { + currentIndex = zoomLevels.indexOf(PdfZoomLevel.ThreeQuarters) + } + this.previewZoomScale = PdfZoomScale.PageWidth + this.previewZoomSetting = zoomLevels[Math.max(0, currentIndex - 1)] } get showPermissions(): boolean { diff --git a/src-ui/src/app/components/document-detail/zoom-setting.ts b/src-ui/src/app/components/document-detail/zoom-setting.ts deleted file mode 100644 index 27d4f1677..000000000 --- a/src-ui/src/app/components/document-detail/zoom-setting.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum ZoomSetting { - PageFit = 'page-fit', - PageWidth = 'page-width', - Quarter = '.25', - Half = '.5', - ThreeQuarters = '.75', - One = '1', - OneAndHalf = '1.5', - Two = '2', - Three = '3', -} diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index f899cffa4..9b72cb1d6 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -1,5 +1,5 @@ import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode' -import { ZoomSetting } from '../components/document-detail/zoom-setting' +import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types' import { User } from './user' export interface UiSettings { @@ -310,7 +310,7 @@ export const SETTINGS: UiSetting[] = [ { key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING, type: 'string', - default: ZoomSetting.PageWidth, + default: PdfZoomScale.PageWidth, }, { key: SETTINGS_KEYS.AI_ENABLED, diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index b03ce4c2b..cccfc96db 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -21,7 +21,6 @@ import { NgbModule, } from '@ng-bootstrap/ng-bootstrap' import { NgSelectModule } from '@ng-select/ng-select' -import { PdfViewerModule } from 'ng2-pdf-viewer' import { NgxBootstrapIconsModule, airplane, @@ -371,7 +370,6 @@ bootstrapApplication(AppComponent, { NgbModule, FormsModule, ReactiveFormsModule, - PdfViewerModule, NgSelectModule, ColorSliderModule, DragDropModule, diff --git a/src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts b/src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts new file mode 100644 index 000000000..30e3ac410 --- /dev/null +++ b/src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts @@ -0,0 +1,24 @@ +export class PDFDocumentProxy { + numPages = 1 +} + +export class PDFDocumentLoadingTask { + promise: Promise + destroyed = false + + constructor(promise: Promise) { + this.promise = promise + } + + destroy(): void { + this.destroyed = true + } +} + +export const GlobalWorkerOptions = { + workerSrc: '', +} + +export const getDocument = (_src: unknown): PDFDocumentLoadingTask => { + return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy())) +} diff --git a/src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts b/src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts new file mode 100644 index 000000000..601b05caf --- /dev/null +++ b/src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts @@ -0,0 +1,79 @@ +type EventHandler = (event?: unknown) => void + +export class EventBus { + private readonly listeners = new Map>() + + on(eventName: string, listener: EventHandler): void { + let listeners = this.listeners.get(eventName) + if (!listeners) { + listeners = new Set() + this.listeners.set(eventName, listeners) + } + listeners.add(listener) + } + + off(eventName: string, listener: EventHandler): void { + this.listeners.get(eventName)?.delete(listener) + } + + dispatch(eventName: string, event?: unknown): void { + this.listeners.get(eventName)?.forEach((listener) => listener(event)) + } +} + +export class PDFFindController { + onIsPageVisible?: () => boolean +} + +export class PDFLinkService { + private document?: unknown + private viewer?: unknown + + setDocument(document: unknown): void { + this.document = document + } + + setViewer(viewer: unknown): void { + this.viewer = viewer + } +} + +class BaseViewer { + pagesCount = 0 + currentScale = 1 + currentScaleValue: string | number = 1 + pagesRotation = 0 + readonly options: Record + + private readonly eventBus?: EventBus + private _currentPageNumber = 1 + + constructor(options: { eventBus?: EventBus }) { + this.options = options + this.eventBus = options.eventBus + } + + setDocument(document: { numPages?: number } | null | undefined): void { + this.pagesCount = document?.numPages ?? 1 + this.eventBus?.dispatch('pagesinit', {}) + this.eventBus?.dispatch('pagerendered', { + pageNumber: this._currentPageNumber, + }) + } + + cleanup(): void { + this.pagesCount = 0 + } + + get currentPageNumber(): number { + return this._currentPageNumber + } + + set currentPageNumber(value: number) { + this._currentPageNumber = value + this.eventBus?.dispatch('pagechanging', { pageNumber: value }) + } +} + +export class PDFViewer extends BaseViewer {} +export class PDFSinglePageViewer extends BaseViewer {} diff --git a/src-ui/tsconfig.spec.json b/src-ui/tsconfig.spec.json index b32c2ad5b..af936d615 100644 --- a/src-ui/tsconfig.spec.json +++ b/src-ui/tsconfig.spec.json @@ -17,6 +17,7 @@ ], "include": [ "src/**/*.spec.ts", - "src/**/*.d.ts" + "src/**/*.d.ts", + "src/test/**/*.ts" ] }