feat: implement segment-based PDF download functionality with a dedicated UI for multi-page export
Build and Push Multi-Platform Images / build-and-push (push) Successful in 34s

This commit is contained in:
2026-05-06 09:46:23 +02:00
parent e08a5697f0
commit 415f8bbcf3
5 changed files with 162 additions and 29 deletions
@@ -53,6 +53,36 @@ export async function cleanupTemp(filePath: string | null): Promise<void> {
}
}
/**
* 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<Buffer> {
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[],
@@ -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<StreamableFile> {
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)}"`);
+7 -10
View File
@@ -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(
+4 -2
View File
@@ -107,8 +107,10 @@ export const inboxApi = {
)
.then((r) => r.data),
downloadBlob: (id: string) =>
api.get<Blob>(`/api/inbox/${encodeURIComponent(id)}/download`, { responseType: 'blob' }).then((r) => r.data),
downloadSegmentBlob: (id: string, pages: number[]) =>
api
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
.then((r) => r.data),
sendEmail: (
id: string,
+117 -14
View File
@@ -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<string[]>([]);
const [downloading, setDownloading] = useState<number | 'all' | null>(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 (
<Modal
open={open}
title="Dokumente herunterladen"
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
Schließen
</Button>,
<Button key="all" type="primary" icon={<SaveOutlined />} loading={downloading === 'all'} disabled={downloading !== null && downloading !== 'all'} onClick={downloadAll}>
Alle herunterladen
</Button>,
]}
width={560}
destroyOnClose
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 16 }}>
{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 (
<div key={doc.index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ minWidth: 90, color: '#888', fontSize: 12 }}>{range}</span>
<Input
value={filenames[i] ?? ''}
onChange={(e) =>
setFilenames((prev) => prev.map((f, j) => (j === i ? e.target.value : f)))
}
suffix=".pdf"
style={{ flex: 1 }}
/>
<Button
icon={<SaveOutlined />}
loading={downloading === i}
disabled={downloading !== null && downloading !== i}
onClick={() => downloadOne(i)}
>
Laden
</Button>
</div>
);
})}
</div>
</Modal>
);
}
function TiptapToolbar({ editor }: { editor: ReturnType<typeof useEditor> }) {
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() {
<Dropdown.Button
type="primary"
icon={<DownOutlined />}
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')}
/>
<DownloadSegmentsDialog
open={downloadDialogOpen}
fileId={file.id}
fileName={file.name}
documents={documents}
onClose={() => setDownloadDialogOpen(false)}
/>
<SendEmailDialog
open={emailDialogOpen}
fileId={file.id}