--- /dev/null
+<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>
--- /dev/null
+.pdf-viewer-container {
+ background-color: gray;
+ height: 120px;
+
+ pdf-viewer {
+ width: 100%;
+ height: 100%;
+ }
+ }
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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
+ }
+}
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
</button>
+ <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
+ <i-bs name="pencil"></i-bs> <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> <ng-container i18n>Delete page(s)</ng-container>
</button>
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()
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'
})
}
+ 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',
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,
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.")
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"]
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
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):
"""
"delete_pages": "checksum",
"split": None,
"merge": None,
+ "edit_pdf": None,
"reprocess": "checksum",
}
if method in [
bulk_edit.split,
bulk_edit.merge,
+ bulk_edit.edit_pdf,
]:
parameters["user"] = user
# 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",
)
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")
):