From 415f8bbcf32b2d2cb6846ccf6317c301431e9032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Wed, 6 May 2026 09:46:23 +0200 Subject: [PATCH] feat: implement segment-based PDF download functionality with a dedicated UI for multi-page export --- .../src/inbox-postprocessor/edit-applier.ts | 30 ++++ .../src/inbox/inbox.controller.ts | 7 +- paperless-backend/src/inbox/inbox.service.ts | 17 +-- paperless-frontend/src/api/inbox.ts | 6 +- .../src/pages/InboxDetailPage.tsx | 131 ++++++++++++++++-- 5 files changed, 162 insertions(+), 29 deletions(-) diff --git a/paperless-backend/src/inbox-postprocessor/edit-applier.ts b/paperless-backend/src/inbox-postprocessor/edit-applier.ts index 1d2da97..b858c07 100644 --- a/paperless-backend/src/inbox-postprocessor/edit-applier.ts +++ b/paperless-backend/src/inbox-postprocessor/edit-applier.ts @@ -53,6 +53,36 @@ export async function cleanupTemp(filePath: string | null): Promise { } } +/** + * Baut ein PDF aus einer Teilmenge von Originalseiten auf (rotiert, keine gelöschten Seiten). + * segmentPages enthält 1-basierte Originalseitenzahlen; gelöschte Seiten müssen vom Aufrufer + * bereits herausgefiltert worden sein. + */ +export async function buildSegmentBuffer( + doc: InboxDocument, + pdfPath: string, + segmentPages: number[], +): Promise { + const bytes = await fs.readFile(pdfPath); + const srcPdf = await PDFDocument.load(bytes, { ignoreEncryption: true }); + const outPdf = await PDFDocument.create(); + + const rotations = doc.Rotations ?? {}; + const indices = segmentPages.map((p) => p - 1); + const copied = await outPdf.copyPages(srcPdf, indices); + + copied.forEach((page, i) => { + const rot = rotations[String(segmentPages[i])]; + if (rot !== undefined) { + const normalized = ((Math.round(rot / 90) * 90) % 360 + 360) % 360; + if (normalized !== 0) page.setRotation(degrees(normalized)); + } + outPdf.addPage(page); + }); + + return Buffer.from(await outPdf.save()); +} + export async function extractSectionToTemp( pdfPath: string, pageIndices: number[], diff --git a/paperless-backend/src/inbox/inbox.controller.ts b/paperless-backend/src/inbox/inbox.controller.ts index cdacdcf..48bf81b 100644 --- a/paperless-backend/src/inbox/inbox.controller.ts +++ b/paperless-backend/src/inbox/inbox.controller.ts @@ -176,14 +176,15 @@ export class InboxController { await this.inboxService.updateSource(id, body.source, preferredUsername); } - @Get(':id/download') - async download( + @Post(':id/download-segment') + async downloadSegment( @Param('id') id: string, + @Body() body: { pages: number[] }, @Request() req: any, @Res({ passthrough: true }) res: Response, ): Promise { const preferredUsername: string | null = req.user?.preferredUsername ?? null; - const { buffer, filename } = await this.inboxService.getEditedPdfBuffer(id, preferredUsername); + const { buffer, filename } = await this.inboxService.getSegmentPdfBuffer(id, preferredUsername, body.pages ?? []); const { Readable } = await import('stream'); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`); diff --git a/paperless-backend/src/inbox/inbox.service.ts b/paperless-backend/src/inbox/inbox.service.ts index 5c25c88..5f64788 100644 --- a/paperless-backend/src/inbox/inbox.service.ts +++ b/paperless-backend/src/inbox/inbox.service.ts @@ -15,7 +15,7 @@ import { type InboxSource, } from '../database/entities/inbox-document.entity'; import { MailService } from '../postprocessing/mail.service'; -import { applyEditsToTemp, cleanupTemp } from '../inbox-postprocessor/edit-applier'; +import { applyEditsToTemp, cleanupTemp, buildSegmentBuffer } from '../inbox-postprocessor/edit-applier'; export interface InboxFile { id: string; @@ -253,19 +253,16 @@ export class InboxService { return this.barcodeScanner.scanRegion(doc, pdfPath, page, x, y, w, h); } - async getEditedPdfBuffer( + async getSegmentPdfBuffer( id: string, preferredUsername: string | null, + pages: number[], ): Promise<{ buffer: Buffer; filename: string }> { const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); - let tmpPath: string | null = null; - try { - tmpPath = await applyEditsToTemp(doc, pdfPath); - const buffer = await fs.readFile(tmpPath); - return { buffer, filename: doc.OriginalName }; - } finally { - await cleanupTemp(tmpPath); - } + const deleted = new Set(doc.DeletedPages ?? []); + const safePages = pages.filter((p) => p >= 1 && p <= doc.PageCount && !deleted.has(p)); + const buffer = await buildSegmentBuffer(doc, pdfPath, safePages); + return { buffer, filename: doc.OriginalName }; } async sendAsEmail( diff --git a/paperless-frontend/src/api/inbox.ts b/paperless-frontend/src/api/inbox.ts index 630f2ec..df45897 100644 --- a/paperless-frontend/src/api/inbox.ts +++ b/paperless-frontend/src/api/inbox.ts @@ -107,8 +107,10 @@ export const inboxApi = { ) .then((r) => r.data), - downloadBlob: (id: string) => - api.get(`/api/inbox/${encodeURIComponent(id)}/download`, { responseType: 'blob' }).then((r) => r.data), + downloadSegmentBlob: (id: string, pages: number[]) => + api + .post(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' }) + .then((r) => r.data), sendEmail: ( id: string, diff --git a/paperless-frontend/src/pages/InboxDetailPage.tsx b/paperless-frontend/src/pages/InboxDetailPage.tsx index ebd5aaf..c7e5df5 100644 --- a/paperless-frontend/src/pages/InboxDetailPage.tsx +++ b/paperless-frontend/src/pages/InboxDetailPage.tsx @@ -529,6 +529,113 @@ function PostprocessWizardModal({ } +interface DownloadSegmentsDialogProps { + open: boolean; + fileId: string; + fileName: string; + documents: DocumentSegment[]; + onClose: () => void; +} + +function DownloadSegmentsDialog({ open, fileId, fileName, documents, onClose }: DownloadSegmentsDialogProps) { + const [filenames, setFilenames] = useState([]); + const [downloading, setDownloading] = useState(null); + + useEffect(() => { + if (!open) return; + const base = fileName.replace(/\.pdf$/i, ''); + setFilenames( + documents.map((doc) => + doc.belegname || (documents.length === 1 ? base : `${base}_${doc.index + 1}`), + ), + ); + setDownloading(null); + }, [open, documents, fileName]); + + const triggerDownload = (blob: Blob, name: string) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name.endsWith('.pdf') ? name : `${name}.pdf`; + a.click(); + URL.revokeObjectURL(url); + }; + + const downloadOne = async (idx: number) => { + setDownloading(idx); + try { + const blob = await inboxApi.downloadSegmentBlob(fileId, documents[idx].pages); + triggerDownload(blob, filenames[idx] || fileName); + } catch { + message.error('Download fehlgeschlagen'); + } finally { + setDownloading(null); + } + }; + + const downloadAll = async () => { + setDownloading('all'); + try { + for (let i = 0; i < documents.length; i++) { + const blob = await inboxApi.downloadSegmentBlob(fileId, documents[i].pages); + triggerDownload(blob, filenames[i] || fileName); + if (i < documents.length - 1) await new Promise((r) => setTimeout(r, 300)); + } + } catch { + message.error('Download fehlgeschlagen'); + } finally { + setDownloading(null); + } + }; + + return ( + + Schließen + , + , + ]} + width={560} + destroyOnClose + > +
+ {documents.map((doc, i) => { + const first = doc.pages[0]; + const last = doc.pages[doc.pages.length - 1]; + const range = first === last ? `Seite ${first}` : `Seiten ${first}–${last}`; + return ( +
+ {range} + + setFilenames((prev) => prev.map((f, j) => (j === i ? e.target.value : f))) + } + suffix=".pdf" + style={{ flex: 1 }} + /> + +
+ ); + })} +
+
+ ); +} + function TiptapToolbar({ editor }: { editor: ReturnType }) { if (!editor) return null; const btnStyle = (active: boolean): React.CSSProperties => ({ @@ -638,7 +745,7 @@ export default function InboxDetailPage() { const [selectedPage, setSelectedPage] = useState(1); const [zoom, setZoom] = useState(1); const [wizardOpen, setWizardOpen] = useState(false); - const [downloading, setDownloading] = useState(false); + const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); const [emailDialogOpen, setEmailDialogOpen] = useState(false); const [scanMode, setScanMode] = useState(false); const [scanning, setScanning] = useState(false); @@ -1005,8 +1112,7 @@ export default function InboxDetailPage() { } - disabled={documents.length === 0 || downloading} - loading={downloading} + disabled={documents.length === 0} onClick={() => setWizardOpen(true)} menu={{ items: [ @@ -1015,17 +1121,7 @@ export default function InboxDetailPage() { ] as MenuProps['items'], onClick: ({ key }) => { if (key === 'save') { - setDownloading(true); - inboxApi.downloadBlob(file.id).then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = file.name; - a.click(); - URL.revokeObjectURL(url); - }).catch(() => { - message.error('Download fehlgeschlagen'); - }).finally(() => setDownloading(false)); + setDownloadDialogOpen(true); } if (key === 'email') setEmailDialogOpen(true); }, @@ -1454,6 +1550,13 @@ export default function InboxDetailPage() { onClose={() => setWizardOpen(false)} onDeleted={() => navigate('/inbox')} /> + setDownloadDialogOpen(false)} + />