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
Build and Push Multi-Platform Images / build-and-push (push) Successful in 34s
This commit is contained in:
@@ -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(
|
export async function extractSectionToTemp(
|
||||||
pdfPath: string,
|
pdfPath: string,
|
||||||
pageIndices: number[],
|
pageIndices: number[],
|
||||||
|
|||||||
@@ -176,14 +176,15 @@ export class InboxController {
|
|||||||
await this.inboxService.updateSource(id, body.source, preferredUsername);
|
await this.inboxService.updateSource(id, body.source, preferredUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/download')
|
@Post(':id/download-segment')
|
||||||
async download(
|
async downloadSegment(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
@Body() body: { pages: number[] },
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
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');
|
const { Readable } = await import('stream');
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type InboxSource,
|
type InboxSource,
|
||||||
} from '../database/entities/inbox-document.entity';
|
} from '../database/entities/inbox-document.entity';
|
||||||
import { MailService } from '../postprocessing/mail.service';
|
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 {
|
export interface InboxFile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -253,19 +253,16 @@ export class InboxService {
|
|||||||
return this.barcodeScanner.scanRegion(doc, pdfPath, page, x, y, w, h);
|
return this.barcodeScanner.scanRegion(doc, pdfPath, page, x, y, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEditedPdfBuffer(
|
async getSegmentPdfBuffer(
|
||||||
id: string,
|
id: string,
|
||||||
preferredUsername: string | null,
|
preferredUsername: string | null,
|
||||||
|
pages: number[],
|
||||||
): Promise<{ buffer: Buffer; filename: string }> {
|
): Promise<{ buffer: Buffer; filename: string }> {
|
||||||
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
|
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
|
||||||
let tmpPath: string | null = null;
|
const deleted = new Set(doc.DeletedPages ?? []);
|
||||||
try {
|
const safePages = pages.filter((p) => p >= 1 && p <= doc.PageCount && !deleted.has(p));
|
||||||
tmpPath = await applyEditsToTemp(doc, pdfPath);
|
const buffer = await buildSegmentBuffer(doc, pdfPath, safePages);
|
||||||
const buffer = await fs.readFile(tmpPath);
|
|
||||||
return { buffer, filename: doc.OriginalName };
|
return { buffer, filename: doc.OriginalName };
|
||||||
} finally {
|
|
||||||
await cleanupTemp(tmpPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendAsEmail(
|
async sendAsEmail(
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ export const inboxApi = {
|
|||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
downloadBlob: (id: string) =>
|
downloadSegmentBlob: (id: string, pages: number[]) =>
|
||||||
api.get<Blob>(`/api/inbox/${encodeURIComponent(id)}/download`, { responseType: 'blob' }).then((r) => r.data),
|
api
|
||||||
|
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
sendEmail: (
|
sendEmail: (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -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> }) {
|
function TiptapToolbar({ editor }: { editor: ReturnType<typeof useEditor> }) {
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
const btnStyle = (active: boolean): React.CSSProperties => ({
|
const btnStyle = (active: boolean): React.CSSProperties => ({
|
||||||
@@ -638,7 +745,7 @@ export default function InboxDetailPage() {
|
|||||||
const [selectedPage, setSelectedPage] = useState(1);
|
const [selectedPage, setSelectedPage] = useState(1);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||||
const [emailDialogOpen, setEmailDialogOpen] = useState(false);
|
const [emailDialogOpen, setEmailDialogOpen] = useState(false);
|
||||||
const [scanMode, setScanMode] = useState(false);
|
const [scanMode, setScanMode] = useState(false);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
@@ -1005,8 +1112,7 @@ export default function InboxDetailPage() {
|
|||||||
<Dropdown.Button
|
<Dropdown.Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<DownOutlined />}
|
icon={<DownOutlined />}
|
||||||
disabled={documents.length === 0 || downloading}
|
disabled={documents.length === 0}
|
||||||
loading={downloading}
|
|
||||||
onClick={() => setWizardOpen(true)}
|
onClick={() => setWizardOpen(true)}
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
@@ -1015,17 +1121,7 @@ export default function InboxDetailPage() {
|
|||||||
] as MenuProps['items'],
|
] as MenuProps['items'],
|
||||||
onClick: ({ key }) => {
|
onClick: ({ key }) => {
|
||||||
if (key === 'save') {
|
if (key === 'save') {
|
||||||
setDownloading(true);
|
setDownloadDialogOpen(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));
|
|
||||||
}
|
}
|
||||||
if (key === 'email') setEmailDialogOpen(true);
|
if (key === 'email') setEmailDialogOpen(true);
|
||||||
},
|
},
|
||||||
@@ -1454,6 +1550,13 @@ export default function InboxDetailPage() {
|
|||||||
onClose={() => setWizardOpen(false)}
|
onClose={() => setWizardOpen(false)}
|
||||||
onDeleted={() => navigate('/inbox')}
|
onDeleted={() => navigate('/inbox')}
|
||||||
/>
|
/>
|
||||||
|
<DownloadSegmentsDialog
|
||||||
|
open={downloadDialogOpen}
|
||||||
|
fileId={file.id}
|
||||||
|
fileName={file.name}
|
||||||
|
documents={documents}
|
||||||
|
onClose={() => setDownloadDialogOpen(false)}
|
||||||
|
/>
|
||||||
<SendEmailDialog
|
<SendEmailDialog
|
||||||
open={emailDialogOpen}
|
open={emailDialogOpen}
|
||||||
fileId={file.id}
|
fileId={file.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user