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 new file mode 100644 index 000000000..229db02a0 --- /dev/null +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.html @@ -0,0 +1,29 @@ + + + + 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 new file mode 100644 index 000000000..72bb4e5b5 --- /dev/null +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.scss @@ -0,0 +1,9 @@ +.pdf-viewer-container { + background-color: gray; + height: 120px; + + pdf-viewer { + width: 100%; + height: 100%; + } + } diff --git a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.spec.ts b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.spec.ts new file mode 100644 index 000000000..d258e7462 --- /dev/null +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.spec.ts @@ -0,0 +1,36 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { PDFEditorComponent } from './pdf-editor.component' + +describe('PDFEditorComponent', () => { + let component: PDFEditorComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: NgbActiveModal, useValue: {} }, + ], + }).compileComponents() + fixture = TestBed.createComponent(PDFEditorComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should rotate and reorder pages', () => { + component.pages = [ + { page: 1, rotate: 0, splitAfter: false }, + { page: 2, rotate: 0, splitAfter: false }, + ] + component.rotate(0) + expect(component.pages[0].rotate).toBe(90) + component.drop({ previousIndex: 0, currentIndex: 1 } as any) + expect(component.pages[0].page).toBe(2) + }) +}) 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 new file mode 100644 index 000000000..ebedbc9c0 --- /dev/null +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts @@ -0,0 +1,90 @@ +import { + CdkDragDrop, + DragDropModule, + moveItemInArray, +} from '@angular/cdk/drag-drop' +import { Component, OnInit, 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 { DocumentService } from 'src/app/services/rest/document.service' +import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' + +interface PageOperation { + page: number + rotate: number + splitAfter: boolean +} + +@Component({ + selector: 'pngx-pdf-editor', + templateUrl: './pdf-editor.component.html', + styleUrl: './pdf-editor.component.scss', + imports: [ + DragDropModule, + FormsModule, + PdfViewerModule, + NgxBootstrapIconsModule, + ], +}) +export class PDFEditorComponent + extends ConfirmDialogComponent + implements OnInit +{ + private documentService = inject(DocumentService) + activeModal = inject(NgbActiveModal) + + documentID: number + pages: PageOperation[] = [] + totalPages = 0 + deleteOriginal = false + + get pdfSrc(): string { + return this.documentService.getPreviewUrl(this.documentID) + } + + ngOnInit() {} + + pdfLoaded(pdf: PDFDocumentProxy) { + this.totalPages = pdf.numPages + this.pages = Array.from({ length: this.totalPages }, (_, i) => ({ + page: i + 1, + rotate: 0, + splitAfter: false, + })) + } + + rotate(i: number) { + this.pages[i].rotate = (this.pages[i].rotate + 90) % 360 + } + + remove(i: number) { + this.pages.splice(i, 1) + } + + toggleSplit(i: number) { + this.pages[i].splitAfter = !this.pages[i].splitAfter + } + + drop(event: CdkDragDrop) { + moveItemInArray(this.pages, event.previousIndex, event.currentIndex) + } + + getOperations() { + const operations = this.pages.map((p, idx) => ({ + page: p.page, + rotate: p.rotate, + doc: this.computeDocIndex(idx), + })) + return operations + } + + private computeDocIndex(index: number): number { + let docIndex = 0 + for (let i = 0; i <= index; i++) { + if (this.pages[i].splitAfter && i < index) docIndex++ + } + return docIndex + } +} 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 067246335..5ae7ee677 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 @@ -66,6 +66,10 @@  Rotate + + 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 9be722c3a..360e4bedc 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 @@ -1219,6 +1219,29 @@ describe('DocumentDetailComponent', () => { req.flush(true) }) + it('should support pdf editor', () => { + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[0])) + initNormally() + component.editPdf() + expect(modal).not.toBeUndefined() + modal.componentInstance.documentID = doc.id + modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] + modal.componentInstance.confirm() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + expect(req.request.body).toEqual({ + documents: [doc.id], + method: 'edit_pdf', + parameters: { + operations: [{ page: 1, rotate: 0, doc: 0 }], + delete_original: false, + }, + }) + req.flush(true) + }) + it('should support keyboard shortcuts', () => { initNormally() 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 e8a05962c..6704806c2 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 @@ -101,6 +101,7 @@ import { TagsComponent } from '../common/input/tags/tags.component' import { TextComponent } from '../common/input/text/text.component' import { UrlComponent } from '../common/input/url/url.component' import { PageHeaderComponent } from '../common/page-header/page-header.component' +import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component' import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' import { DocumentHistoryComponent } from '../document-history/document-history.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component' @@ -1417,6 +1418,45 @@ export class DocumentDetailComponent }) } + editPdf() { + let modal = this.modalService.open(PDFEditorComponent, { + backdrop: 'static', + size: 'xl', + scrollable: true, + }) + modal.componentInstance.title = $localize`Edit PDF` + modal.componentInstance.btnCaption = $localize`Proceed` + modal.componentInstance.documentID = this.document.id + modal.componentInstance.confirmClicked + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + modal.componentInstance.buttonsEnabled = false + this.documentsService + .bulkEdit([this.document.id], 'edit_pdf', { + operations: modal.componentInstance.getOperations(), + delete_original: modal.componentInstance.deleteOriginal, + }) + .pipe(first(), takeUntil(this.unsubscribeNotifier)) + .subscribe({ + next: () => { + this.toastService.showInfo( + $localize`PDF edit operation for "${this.document.title}" will begin in the background.` + ) + modal.close() + }, + error: (error) => { + if (modal) { + modal.componentInstance.buttonsEnabled = true + } + this.toastService.showError( + $localize`Error executing PDF edit operation`, + error + ) + }, + }) + }) + } + deletePages() { let modal = this.modalService.open(DeletePagesConfirmDialogComponent, { backdrop: 'static', diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 13773fe87..9b1740a65 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -497,6 +497,77 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]: return "OK" +def edit_pdf( + doc_ids: list[int], + operations: list[dict], + *, + delete_original: bool = False, + user: User | None = None, +) -> Literal["OK"]: + """ + Operations is a list of dictionaries describing the final PDF pages. + Each entry must contain the original page number in `page` and may + specify `rotate` in degrees and `doc` indicating the output + document index (for splitting). Pages omitted from the list are + discarded. + """ + + logger.info( + f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations", + ) + doc = Document.objects.get(id=doc_ids[0]) + import pikepdf + + pdf_docs: list[pikepdf.Pdf] = [] + + try: + with pikepdf.open(doc.source_path) as src: + # prepare output documents + max_idx = max(op.get("doc", 0) for op in operations) + pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)] + + for op in operations: + dst = pdf_docs[op.get("doc", 0)] + page = src.pages[op["page"] - 1] + dst.pages.append(page) + if op.get("rotate"): + dst.pages[-1].rotate(op["rotate"], relative=True) + + consume_tasks = [] + overrides: DocumentMetadataOverrides = ( + DocumentMetadataOverrides().from_document(doc) + ) + if user is not None: + overrides.owner_id = user.id + + for idx, pdf in enumerate(pdf_docs, start=1): + filepath: Path = ( + Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) + / f"{doc.id}_edit_{idx}.pdf" + ) + pdf.remove_unreferenced_resources() + pdf.save(filepath) + consume_tasks.append( + consume_file.s( + ConsumableDocument( + source=DocumentSource.ConsumeFolder, + original_file=filepath, + ), + overrides, + ), + ) + + if delete_original: + chord(header=consume_tasks, body=delete.si([doc.id])).delay() + else: + group(consume_tasks).delay() + + except Exception as e: + logger.exception(f"Error editing document {doc.id}: {e}") + + return "OK" + + def reflect_doclinks( document: Document, field: CustomField, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 48497ce19..2ddc9b726 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1366,6 +1366,8 @@ class BulkEditSerializer( return bulk_edit.split elif method == "delete_pages": return bulk_edit.delete_pages + elif method == "edit_pdf": + return bulk_edit.edit_pdf else: raise serializers.ValidationError("Unsupported method.") @@ -1520,6 +1522,26 @@ class BulkEditSerializer( else: parameters["archive_fallback"] = False + def _validate_parameters_edit_pdf(self, parameters): + if "operations" not in parameters: + raise serializers.ValidationError("operations not specified") + if not isinstance(parameters["operations"], list): + raise serializers.ValidationError("operations must be a list") + for op in parameters["operations"]: + if not isinstance(op, dict): + raise serializers.ValidationError("invalid operation entry") + if "page" not in op or not isinstance(op["page"], int): + raise serializers.ValidationError("page must be an integer") + if "rotate" in op and not isinstance(op["rotate"], int): + raise serializers.ValidationError("rotate must be an integer") + if "doc" in op and not isinstance(op["doc"], int): + raise serializers.ValidationError("doc must be an integer") + if "delete_original" in parameters: + if not isinstance(parameters["delete_original"], bool): + raise serializers.ValidationError("delete_original must be a boolean") + else: + parameters["delete_original"] = False + def validate(self, attrs): method = attrs["method"] parameters = attrs["parameters"] @@ -1554,6 +1576,12 @@ class BulkEditSerializer( self._validate_parameters_delete_pages(parameters) elif method == bulk_edit.merge: self._validate_parameters_merge(parameters) + elif method == bulk_edit.edit_pdf: + if len(attrs["documents"]) > 1: + raise serializers.ValidationError( + "Edit PDF method only supports one document", + ) + self._validate_parameters_edit_pdf(parameters) return attrs diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index bcbe5922d..33f1e9bec 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -1369,6 +1369,60 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"pages must be a list of integers", response.content) + @mock.patch("documents.serialisers.bulk_edit.edit_pdf") + def test_edit_pdf(self, m): + self.setup_mock(m, "edit_pdf") + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "edit_pdf", + "parameters": {"operations": [{"page": 1}]}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + m.assert_called_once() + args, kwargs = m.call_args + self.assertCountEqual(args[0], [self.doc2.id]) + self.assertEqual(kwargs["operations"], [{"page": 1}]) + self.assertEqual(kwargs["user"], self.user) + + def test_edit_pdf_invalid_params(self): + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "method": "edit_pdf", + "parameters": {"operations": [{"page": 1}]}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"Edit PDF method only supports one document", response.content) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "edit_pdf", + "parameters": {}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"operations not specified", response.content) + @override_settings(AUDIT_LOG_ENABLED=True) def test_bulk_edit_audit_log_enabled_simple_field(self): """ diff --git a/src/documents/views.py b/src/documents/views.py index 74d1ff3ea..f0f870e1b 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1314,6 +1314,7 @@ class BulkEditView(PassUserMixin): "delete_pages": "checksum", "split": None, "merge": None, + "edit_pdf": None, "reprocess": "checksum", } @@ -1332,6 +1333,7 @@ class BulkEditView(PassUserMixin): if method in [ bulk_edit.split, bulk_edit.merge, + bulk_edit.edit_pdf, ]: parameters["user"] = user @@ -1351,24 +1353,29 @@ class BulkEditView(PassUserMixin): # check ownership for methods that change original document if ( - has_perms - and method - in [ - bulk_edit.set_permissions, - bulk_edit.delete, - bulk_edit.rotate, - bulk_edit.delete_pages, - ] - ) or ( - method in [bulk_edit.merge, bulk_edit.split] - and parameters["delete_originals"] + ( + has_perms + and method + in [ + bulk_edit.set_permissions, + bulk_edit.delete, + bulk_edit.rotate, + bulk_edit.delete_pages, + bulk_edit.edit_pdf, + ] + ) + or ( + method in [bulk_edit.merge, bulk_edit.split] + and parameters["delete_originals"] + ) + or (method == bulk_edit.edit_pdf and parameters["delete_original"]) ): has_perms = user_is_owner_of_all_documents # check global add permissions for methods that create documents if ( has_perms - and method in [bulk_edit.split, bulk_edit.merge] + and method in [bulk_edit.split, bulk_edit.merge, bulk_edit.edit_pdf] and not user.has_perm( "documents.add_document", ) @@ -1384,6 +1391,7 @@ class BulkEditView(PassUserMixin): method in [bulk_edit.merge, bulk_edit.split] and parameters["delete_originals"] ) + or (method == bulk_edit.edit_pdf and parameters["delete_original"]) ) and not user.has_perm("documents.delete_document") ):