]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Just save this
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 1 Jul 2025 18:35:08 +0000 (11:35 -0700)
committershamoon <4887959+shamoon@users.noreply.github.com>
Sat, 2 Aug 2025 12:22:55 +0000 (08:22 -0400)
[ci skip]

src-ui/src/app/components/common/pdf-editor/pdf-editor.component.html [new file with mode: 0644]
src-ui/src/app/components/common/pdf-editor/pdf-editor.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/pdf-editor/pdf-editor.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts [new file with mode: 0644]
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/documents/bulk_edit.py
src/documents/serialisers.py
src/documents/tests/test_api_bulk_edit.py
src/documents/views.py

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 (file)
index 0000000..229db02
--- /dev/null
@@ -0,0 +1,29 @@
+<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
+<div class="modal-header">
+  <h4 class="modal-title">{{ title }}</h4>
+  <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
+</div>
+<div class="modal-body">
+  <div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-5">
+    @for (p of pages; track p.page; let i = $index) {
+      <div class="page-item p-2" cdkDrag>
+        <div class="btn-group mb-1">
+          <button class="btn btn-sm btn-secondary" (click)="rotate(i)"><i-bs name="arrow-clockwise"></i-bs></button>
+          <button class="btn btn-sm btn-outline-secondary" (click)="toggleSplit(i)"><i-bs name="scissors"></i-bs></button>
+          <button class="btn btn-sm btn-danger" (click)="remove(i)"><i-bs name="trash"></i-bs></button>
+        </div>
+        <div class="pdf-viewer-container w-100 mt-3">
+          <pdf-viewer [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false"></pdf-viewer>
+        </div>
+      </div>
+    }
+  </div>
+</div>
+<div class="modal-footer">
+  <div class="form-check form-switch me-auto">
+    <input class="form-check-input" type="checkbox" id="deleteSwitch" [(ngModel)]="deleteOriginal">
+    <label class="form-check-label" for="deleteSwitch" i18n>Delete original after edit</label>
+  </div>
+  <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
+  <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
+</div>
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 (file)
index 0000000..72bb4e5
--- /dev/null
@@ -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 (file)
index 0000000..d258e74
--- /dev/null
@@ -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<PDFEditorComponent>
+
+  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 (file)
index 0000000..ebedbc9
--- /dev/null
@@ -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<PageOperation[]>) {
+    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
+  }
+}
index 0672463354e1654817e0d39dcafc51eef54d7497..5ae7ee677da94b571b1a11e73743a85d29a6b33e 100644 (file)
         <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
       </button>
 
+      <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
+        <i-bs name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit PDF</ng-container>
+      </button>
+
       <button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
         <i-bs name="file-earmark-minus"></i-bs>&nbsp;<ng-container i18n>Delete page(s)</ng-container>
       </button>
index 80b160171cedc268a53a0177c33e6b4c82d5f4fe..374d299a27b29a91368e66a0723137d7524619b1 100644 (file)
@@ -1235,6 +1235,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()
 
index 55b8ade3974687b03c98f03af8024ba3f89fd40d..1da664fe32d619333f5106e29482d7b7eb49497a 100644 (file)
@@ -102,6 +102,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'
@@ -1430,6 +1431,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',
index 13773fe87d4c2e423fdec642ee77603e310bc5e0..9b1740a65aa57abe0cf5aa495af519e729682baf 100644 (file)
@@ -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,
index 5a1a6c6859aae94fc88c5cf4a564dc30fb3461b3..23219f6afcb81902ac2cf661649cd5528126d20d 100644 (file)
@@ -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
 
index bcbe5922d09fecfc48360a34b4529c3b50ab03db..33f1e9bec1f489136e82b19a8facb0d23955096a 100644 (file)
@@ -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):
         """
index b84267d75e7e369a05834e5e5b1fbeef68e76f5c..94329043c8c5dc21f4269098872b464a7d90e12e 100644 (file)
@@ -1321,6 +1321,7 @@ class BulkEditView(PassUserMixin):
         "delete_pages": "checksum",
         "split": None,
         "merge": None,
+        "edit_pdf": None,
         "reprocess": "checksum",
     }
 
@@ -1339,6 +1340,7 @@ class BulkEditView(PassUserMixin):
         if method in [
             bulk_edit.split,
             bulk_edit.merge,
+            bulk_edit.edit_pdf,
         ]:
             parameters["user"] = user
 
@@ -1358,24 +1360,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",
                 )
@@ -1391,6 +1398,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")
             ):