]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: Add print button (#10626)
authorMattia Paletti <59086526+mpaletti@users.noreply.github.com>
Thu, 11 Sep 2025 17:59:11 +0000 (19:59 +0200)
committerGitHub <noreply@github.com>
Thu, 11 Sep 2025 17:59:11 +0000 (17:59 +0000)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
src-ui/src/app/components/document-detail/document-detail.component.html
src-ui/src/app/components/document-detail/document-detail.component.spec.ts
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/main.ts

index c926c82d9ff12703dc563fa331c29e896c2544c0..42b307e58a1572b797c7824246d9553524273cfb 100644 (file)
         <i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<span i18n>Reprocess</span>
       </button>
 
+      <button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
+        <i-bs width="1em" height="1em" name="printer"></i-bs>&nbsp;<span i18n>Print</span>
+      </button>
+
       <button ngbDropdownItem (click)="moreLike()">
         <i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span>
       </button>
index ed0d2a1255e2ec997cbebdca94fc947cc4815bcd..97dae19b792dda56eaf9ebae203a283c8213704c 100644 (file)
@@ -1415,4 +1415,151 @@ describe('DocumentDetailComponent', () => {
       .flush('fail', { status: 500, statusText: 'Server Error' })
     expect(component.previewText).toContain('An error occurred loading content')
   })
+
+  it('should print document successfully', fakeAsync(() => {
+    initNormally()
+
+    const appendChildSpy = jest
+      .spyOn(document.body, 'appendChild')
+      .mockImplementation((node: Node) => node)
+    const removeChildSpy = jest
+      .spyOn(document.body, 'removeChild')
+      .mockImplementation((node: Node) => node)
+    const createObjectURLSpy = jest
+      .spyOn(URL, 'createObjectURL')
+      .mockReturnValue('blob:mock-url')
+    const revokeObjectURLSpy = jest
+      .spyOn(URL, 'revokeObjectURL')
+      .mockImplementation(() => {})
+
+    const mockContentWindow = {
+      focus: jest.fn(),
+      print: jest.fn(),
+      onafterprint: null,
+    }
+
+    const mockIframe = {
+      style: {},
+      src: '',
+      onload: null,
+      contentWindow: mockContentWindow,
+    }
+
+    const createElementSpy = jest
+      .spyOn(document, 'createElement')
+      .mockReturnValue(mockIframe as any)
+
+    const blob = new Blob(['test'], { type: 'application/pdf' })
+    component.printDocument()
+
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/${doc.id}/download/`
+    )
+    req.flush(blob)
+
+    tick()
+
+    expect(createElementSpy).toHaveBeenCalledWith('iframe')
+    expect(appendChildSpy).toHaveBeenCalledWith(mockIframe)
+    expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
+
+    if (mockIframe.onload) {
+      mockIframe.onload({} as any)
+    }
+
+    expect(mockContentWindow.focus).toHaveBeenCalled()
+    expect(mockContentWindow.print).toHaveBeenCalled()
+
+    if (mockIframe.onload) {
+      mockIframe.onload(new Event('load'))
+    }
+
+    if (mockContentWindow.onafterprint) {
+      mockContentWindow.onafterprint(new Event('afterprint'))
+    }
+
+    expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
+    expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
+
+    createElementSpy.mockRestore()
+    appendChildSpy.mockRestore()
+    removeChildSpy.mockRestore()
+    createObjectURLSpy.mockRestore()
+    revokeObjectURLSpy.mockRestore()
+  }))
+
+  it('should show error toast if print document fails', () => {
+    initNormally()
+    const toastSpy = jest.spyOn(toastService, 'showError')
+    component.printDocument()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/${doc.id}/download/`
+    )
+    req.error(new ErrorEvent('failed'))
+    expect(toastSpy).toHaveBeenCalledWith(
+      'Error loading document for printing.'
+    )
+  })
+
+  it('should show error toast if printing throws inside iframe', fakeAsync(() => {
+    initNormally()
+
+    const appendChildSpy = jest
+      .spyOn(document.body, 'appendChild')
+      .mockImplementation((node: Node) => node)
+    const removeChildSpy = jest
+      .spyOn(document.body, 'removeChild')
+      .mockImplementation((node: Node) => node)
+    const createObjectURLSpy = jest
+      .spyOn(URL, 'createObjectURL')
+      .mockReturnValue('blob:mock-url')
+    const revokeObjectURLSpy = jest
+      .spyOn(URL, 'revokeObjectURL')
+      .mockImplementation(() => {})
+
+    const toastSpy = jest.spyOn(toastService, 'showError')
+
+    const mockContentWindow = {
+      focus: jest.fn().mockImplementation(() => {
+        throw new Error('focus failed')
+      }),
+      print: jest.fn(),
+      onafterprint: null,
+    }
+
+    const mockIframe: any = {
+      style: {},
+      src: '',
+      onload: null,
+      contentWindow: mockContentWindow,
+    }
+
+    const createElementSpy = jest
+      .spyOn(document, 'createElement')
+      .mockReturnValue(mockIframe as any)
+
+    const blob = new Blob(['test'], { type: 'application/pdf' })
+    component.printDocument()
+
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/${doc.id}/download/`
+    )
+    req.flush(blob)
+
+    tick()
+
+    if (mockIframe.onload) {
+      mockIframe.onload(new Event('load'))
+    }
+
+    expect(toastSpy).toHaveBeenCalled()
+    expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
+    expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
+
+    createElementSpy.mockRestore()
+    appendChildSpy.mockRestore()
+    removeChildSpy.mockRestore()
+    createObjectURLSpy.mockRestore()
+    revokeObjectURLSpy.mockRestore()
+  }))
 })
index d139550c08eaa6ad44924f7f740d1d9e30910a53..08c9a637cc1604a94928bf8fb6bfc240321d06f9 100644 (file)
@@ -291,6 +291,10 @@ export class DocumentDetailComponent
     return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
   }
 
+  get isMobile(): boolean {
+    return this.deviceDetectorService.isMobile()
+  }
+
   get archiveContentRenderType(): ContentRenderType {
     return this.document?.archived_file_name
       ? this.getRenderType('application/pdf')
@@ -1419,6 +1423,44 @@ export class DocumentDetailComponent
       })
   }
 
+  printDocument() {
+    const printUrl = this.documentsService.getDownloadUrl(
+      this.document.id,
+      false
+    )
+    this.http
+      .get(printUrl, { responseType: 'blob' })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe({
+        next: (blob) => {
+          const blobUrl = URL.createObjectURL(blob)
+          const iframe = document.createElement('iframe')
+          iframe.style.display = 'none'
+          iframe.src = blobUrl
+          document.body.appendChild(iframe)
+          iframe.onload = () => {
+            try {
+              iframe.contentWindow.focus()
+              iframe.contentWindow.print()
+              iframe.contentWindow.onafterprint = () => {
+                document.body.removeChild(iframe)
+                URL.revokeObjectURL(blobUrl)
+              }
+            } catch (err) {
+              this.toastService.showError($localize`Print failed.`, err)
+              document.body.removeChild(iframe)
+              URL.revokeObjectURL(blobUrl)
+            }
+          }
+        },
+        error: () => {
+          this.toastService.showError(
+            $localize`Error loading document for printing.`
+          )
+        },
+      })
+  }
+
   public openShareLinks() {
     const modal = this.modalService.open(ShareLinksDialogComponent)
     modal.componentInstance.documentId = this.document.id
index 029ec72ac45fc2204adfedae05e112c27429dec0..5ed4fe3739f525726c0acfae8265dff7378bb34d 100644 (file)
@@ -110,6 +110,7 @@ import {
   playFill,
   plus,
   plusCircle,
+  printer,
   questionCircle,
   scissors,
   search,
@@ -319,6 +320,7 @@ const icons = {
   playFill,
   plus,
   plusCircle,
+  printer,
   questionCircle,
   scissors,
   search,