Files
opencrm/frontend/src/components/ui/JpgToPdfModal.tsx
T
duffyduck 523eab30d5 JpgToPdfModal: Bilder auf 2400px runterskalieren
Stage: 2 Handy-JPGs → 23 MB PDF. Smartphone-Fotos haben
4000-6000 px Kante, das macht auch ohne Re-Encode 5-10 MB pro
Bild → PDF wird riesig.

Beim Hinzufügen werden Bilder jetzt auf max 2400 px lange Kante
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
Quality 0.92 (Lightroom-Default) persistiert. Vorschau, Rotation/
Flip und PDF-Embed laufen alle auf dem skalierten Bild.

Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF.
2026-06-03 18:29:04 +02:00

516 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { jsPDF } from 'jspdf';
import {
Upload,
RotateCcw,
RotateCw,
FlipHorizontal,
FlipVertical,
Trash2,
FileText,
Repeat,
} from 'lucide-react';
import Modal from './Modal';
import Button from './Button';
interface ImageItem {
id: string;
dataUrl: string;
naturalWidth: number;
naturalHeight: number;
rotation: 0 | 90 | 180 | 270;
flipH: boolean;
flipV: boolean;
fileName: string;
}
interface JpgToPdfModalProps {
isOpen: boolean;
onClose: () => void;
onComplete: (pdfFile: File) => void;
fileNameHint?: string;
}
// Pentest 68.2 (INFO): Self-DoS-Schutz Modal kann sonst den Tab des
// Uploaders selbst zum Absturz bringen. Werte konservativ gewählt:
// 50 Bilder × 25 MB = 1.25 GB ist mehr als jede legitime Vollmacht.
const MAX_IMAGES = 50;
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
// Smartphone-Fotos haben oft 4000-6000 px Kante. Bei JPEG-Quality
// 0.95 sind das 5-10 MB pro Seite, zwei Bilder = >10 MB PDF.
// 2400 px lange Kante entspricht ~290 DPI auf A4 (Druckqualität) und
// reduziert die Pixelmenge auf 25-36 % vom Original → PDF wird
// drastisch kleiner, sichtbarer Unterschied praktisch null.
const MAX_DIMENSION = 2400;
const EMBED_QUALITY = 0.92;
function makeId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Bild-Decode fehlgeschlagen'));
img.src = dataUrl;
});
}
function downscaleIfNeeded(image: HTMLImageElement): HTMLCanvasElement | null {
const w = image.naturalWidth;
const h = image.naturalHeight;
if (w <= MAX_DIMENSION && h <= MAX_DIMENSION) return null;
const scale = MAX_DIMENSION / Math.max(w, h);
const newW = Math.round(w * scale);
const newH = Math.round(h * scale);
const canvas = document.createElement('canvas');
canvas.width = newW;
canvas.height = newH;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, newW, newH);
return canvas;
}
function renderImageToCanvas(image: HTMLImageElement, item: ImageItem): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas-Kontext konnte nicht erstellt werden');
const w = image.naturalWidth;
const h = image.naturalHeight;
const rotated = item.rotation === 90 || item.rotation === 270;
canvas.width = rotated ? h : w;
canvas.height = rotated ? w : h;
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((item.rotation * Math.PI) / 180);
ctx.scale(item.flipH ? -1 : 1, item.flipV ? -1 : 1);
ctx.drawImage(image, -w / 2, -h / 2);
ctx.restore();
return canvas;
}
export default function JpgToPdfModal({
isOpen,
onClose,
onComplete,
fileNameHint,
}: JpgToPdfModalProps) {
const [images, setImages] = useState<ImageItem[]>([]);
const [dragSrcIdx, setDragSrcIdx] = useState<number | null>(null);
const [isOverModal, setIsOverModal] = useState(false);
const [isBuilding, setIsBuilding] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!isOpen) {
setImages([]);
setDragSrcIdx(null);
setIsOverModal(false);
setIsBuilding(false);
setError(null);
}
}, [isOpen]);
const addFiles = useCallback(async (files: FileList | File[]) => {
const list = Array.from(files).filter((f) => f.type.startsWith('image/'));
if (list.length === 0) {
setError('Nur Bilddateien erlaubt (JPG/PNG).');
return;
}
setError(null);
const added: ImageItem[] = [];
for (const file of list) {
// 68.2: Self-DoS-Schutz harte Schranken pro Bild und gesamt.
if (file.size > MAX_IMAGE_BYTES) {
setError(`Bild zu groß (max. ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)} MB): ${file.name || 'unbenannt'}`);
continue;
}
if (images.length + added.length >= MAX_IMAGES) {
setError(`Maximal ${MAX_IMAGES} Bilder pro PDF erlaubt.`);
break;
}
try {
const rawDataUrl = await readFileAsDataUrl(file);
const img = await loadImage(rawDataUrl);
// Beim Hinzufügen direkt auf MAX_DIMENSION runterskalieren, damit
// die Vorschau, das Rendern in der PDF und die finale Dateigröße
// alle auf vernünftigen Pixelmaßen arbeiten.
const downscaled = downscaleIfNeeded(img);
const dataUrl = downscaled
? downscaled.toDataURL('image/jpeg', EMBED_QUALITY)
: rawDataUrl;
const finalW = downscaled ? downscaled.width : img.naturalWidth;
const finalH = downscaled ? downscaled.height : img.naturalHeight;
added.push({
id: makeId(),
dataUrl,
naturalWidth: finalW,
naturalHeight: finalH,
rotation: 0,
flipH: false,
flipV: false,
fileName: file.name || 'clipboard.png',
});
} catch {
setError(`Bild konnte nicht geladen werden: ${file.name || 'unbenannt'}`);
}
}
if (added.length > 0) {
setImages((prev) => [...prev, ...added]);
}
}, [images.length]);
useEffect(() => {
if (!isOpen) return;
const handler = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) files.push(f);
}
}
if (files.length > 0) {
e.preventDefault();
addFiles(files);
}
};
document.addEventListener('paste', handler);
return () => document.removeEventListener('paste', handler);
}, [isOpen, addFiles]);
const rotate = (id: string, delta: 90 | -90 | 180) => {
setImages((prev) =>
prev.map((it) =>
it.id === id
? {
...it,
rotation: ((((it.rotation + delta) % 360) + 360) % 360) as ImageItem['rotation'],
}
: it
)
);
};
const flip = (id: string, axis: 'h' | 'v') => {
setImages((prev) =>
prev.map((it) =>
it.id === id
? { ...it, ...(axis === 'h' ? { flipH: !it.flipH } : { flipV: !it.flipV }) }
: it
)
);
};
const remove = (id: string) => {
setImages((prev) => prev.filter((it) => it.id !== id));
};
const handleDragStart = (idx: number) => (e: React.DragEvent) => {
setDragSrcIdx(idx);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
};
const handleItemDragOver = (e: React.DragEvent) => {
if (dragSrcIdx === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDropOnItem = (idx: number) => (e: React.DragEvent) => {
if (dragSrcIdx === null) return;
e.preventDefault();
e.stopPropagation();
if (dragSrcIdx === idx) {
setDragSrcIdx(null);
return;
}
setImages((prev) => {
const next = [...prev];
const [moved] = next.splice(dragSrcIdx, 1);
next.splice(idx, 0, moved);
return next;
});
setDragSrcIdx(null);
};
const handleModalDragOver = (e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
setIsOverModal(true);
}
};
const handleModalDragLeave = (e: React.DragEvent) => {
if (e.target === e.currentTarget) setIsOverModal(false);
};
const handleModalDrop = (e: React.DragEvent) => {
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
setIsOverModal(false);
addFiles(e.dataTransfer.files);
}
};
const buildPdf = async () => {
if (images.length === 0) return;
setIsBuilding(true);
setError(null);
try {
const a4w = 210;
const a4h = 297;
let pdf: jsPDF | null = null;
for (let i = 0; i < images.length; i++) {
const item = images[i];
const untouched = item.rotation === 0 && !item.flipH && !item.flipV;
// Fix 1: ungedrehte/ungespiegelte Bilder bekommen ihre Original-Bytes
// direkt in die PDF eingebettet kein Canvas-Re-Encode, kein
// Quality-Aufblasen. Ein 2-MB-JPEG bleibt 2 MB statt 8-15 MB zu werden.
// Fix 2: wenn doch transformiert wird (Rotation/Flip), Canvas mit
// Quality 0.95 statt 1.0 visuell identisch für Foto-Inhalte, aber
// 50-70 % kleiner.
let imageData: string;
let imageFormat: 'JPEG' | 'PNG';
let srcW: number;
let srcH: number;
if (untouched) {
imageData = item.dataUrl;
imageFormat = item.dataUrl.startsWith('data:image/png') ? 'PNG' : 'JPEG';
srcW = item.naturalWidth;
srcH = item.naturalHeight;
} else {
const img = await loadImage(item.dataUrl);
const canvas = renderImageToCanvas(img, item);
imageData = canvas.toDataURL('image/jpeg', EMBED_QUALITY);
imageFormat = 'JPEG';
srcW = canvas.width;
srcH = canvas.height;
}
const orientation: 'portrait' | 'landscape' =
srcW > srcH ? 'landscape' : 'portrait';
const pageW = orientation === 'landscape' ? a4h : a4w;
const pageH = orientation === 'landscape' ? a4w : a4h;
if (!pdf) {
pdf = new jsPDF({ orientation, unit: 'mm', format: 'a4' });
} else {
pdf.addPage('a4', orientation);
}
const margin = 5;
const maxW = pageW - 2 * margin;
const maxH = pageH - 2 * margin;
const ratio = Math.min(maxW / srcW, maxH / srcH);
const w = srcW * ratio;
const h = srcH * ratio;
const x = (pageW - w) / 2;
const y = (pageH - h) / 2;
pdf.addImage(imageData, imageFormat, x, y, w, h, undefined, 'SLOW');
}
const blob = pdf!.output('blob');
const base = (fileNameHint || 'bilder').replace(/[^\w.-]+/g, '_').slice(0, 80) || 'bilder';
const file = new File([blob], `${base}.pdf`, { type: 'application/pdf' });
onComplete(file);
onClose();
} catch (e) {
console.error('PDF-Erstellung fehlgeschlagen:', e);
setError('PDF konnte nicht erstellt werden.');
} finally {
setIsBuilding(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="JPGs zu PDF" size="xl">
<div
onDragOver={handleModalDragOver}
onDragLeave={handleModalDragLeave}
onDrop={handleModalDrop}
className={`space-y-4 ${isOverModal ? 'ring-2 ring-blue-400 rounded-lg' : ''}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-gray-600">
Bilder per Klick wählen, hier reinziehen, oder mit{' '}
<kbd className="px-1.5 py-0.5 border rounded text-xs">Strg</kbd>+
<kbd className="px-1.5 py-0.5 border rounded text-xs">V</kbd> aus der Zwischenablage
einfügen.
</p>
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isBuilding}
>
<Upload className="w-4 h-4 mr-1" /> Bilder hinzufügen
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
e.target.value = '';
}}
/>
</div>
{error && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
{error}
</div>
)}
{images.length === 0 ? (
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed rounded-lg p-12 text-center cursor-pointer border-gray-300 hover:border-gray-400 hover:bg-gray-50"
>
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600">
Bilder hier hineinziehen oder zum Auswählen klicken
</p>
<p className="text-xs text-gray-400 mt-1">
JPG / PNG · Reihenfolge per Drag &amp; Drop, einzeln drehen/spiegeln
</p>
</div>
) : (
<>
<div className="text-xs text-gray-500">
{images.length} {images.length === 1 ? 'Bild' : 'Bilder'} · Reihenfolge per
Drag&amp;Drop, einzeln drehen/spiegeln. Jedes Bild = eine Seite.
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 max-h-[60vh] overflow-y-auto p-1">
{images.map((item, idx) => {
const transforms = [
`rotate(${item.rotation}deg)`,
`scaleX(${item.flipH ? -1 : 1})`,
`scaleY(${item.flipV ? -1 : 1})`,
].join(' ');
return (
<div
key={item.id}
draggable
onDragStart={handleDragStart(idx)}
onDragOver={handleItemDragOver}
onDrop={handleDropOnItem(idx)}
className={`border rounded-lg p-2 bg-white shadow-sm cursor-move ${
dragSrcIdx === idx ? 'opacity-40' : ''
}`}
title="Zum Sortieren ziehen"
>
<div className="flex items-center justify-between mb-1.5 text-xs">
<span className="font-semibold text-gray-700">#{idx + 1}</span>
<button
type="button"
onClick={() => remove(item.id)}
className="text-red-500 hover:text-red-700"
title="Entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="aspect-square bg-gray-100 rounded flex items-center justify-center overflow-hidden">
<img
src={item.dataUrl}
alt={item.fileName}
style={{ transform: transforms }}
className="max-w-full max-h-full object-contain transition-transform"
draggable={false}
/>
</div>
<div className="mt-1.5 flex justify-center gap-0.5">
<button
type="button"
onClick={() => rotate(item.id, -90)}
className="p-1 hover:bg-gray-100 rounded"
title="90° gegen Uhrzeigersinn"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => rotate(item.id, 90)}
className="p-1 hover:bg-gray-100 rounded"
title="90° im Uhrzeigersinn"
>
<RotateCw className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => rotate(item.id, 180)}
className="p-1 hover:bg-gray-100 rounded"
title="180° drehen"
>
<Repeat className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => flip(item.id, 'h')}
className={`p-1 rounded ${
item.flipH ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
}`}
title="Horizontal spiegeln"
>
<FlipHorizontal className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => flip(item.id, 'v')}
className={`p-1 rounded ${
item.flipV ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
}`}
title="Vertikal spiegeln"
>
<FlipVertical className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</>
)}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="ghost" onClick={onClose} disabled={isBuilding}>
Abbrechen
</Button>
<Button onClick={buildPdf} disabled={images.length === 0 || isBuilding}>
<FileText className="w-4 h-4 mr-1" />
{isBuilding ? 'Erstelle PDF...' : 'PDF erstellen & hochladen'}
</Button>
</div>
</div>
</Modal>
);
}