523eab30d5
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.
516 lines
18 KiB
TypeScript
516 lines
18 KiB
TypeScript
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 & Drop, einzeln drehen/spiegeln
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="text-xs text-gray-500">
|
||
{images.length} {images.length === 1 ? 'Bild' : 'Bilder'} · Reihenfolge per
|
||
Drag&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>
|
||
);
|
||
}
|