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 { 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 { 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([]); const [dragSrcIdx, setDragSrcIdx] = useState(null); const [isOverModal, setIsOverModal] = useState(false); const [isBuilding, setIsBuilding] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(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 (

Bilder per Klick wählen, hier reinziehen, oder mit{' '} Strg+ V aus der Zwischenablage einfügen.

{ if (e.target.files && e.target.files.length > 0) addFiles(e.target.files); e.target.value = ''; }} />
{error && (
{error}
)} {images.length === 0 ? (
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" >

Bilder hier hineinziehen oder zum Auswählen klicken

JPG / PNG · Reihenfolge per Drag & Drop, einzeln drehen/spiegeln

) : ( <>
{images.length} {images.length === 1 ? 'Bild' : 'Bilder'} · Reihenfolge per Drag&Drop, einzeln drehen/spiegeln. Jedes Bild = eine Seite.
{images.map((item, idx) => { const transforms = [ `rotate(${item.rotation}deg)`, `scaleX(${item.flipH ? -1 : 1})`, `scaleY(${item.flipV ? -1 : 1})`, ].join(' '); return (
#{idx + 1}
{item.fileName}
); })}
)}
); }