This commit is contained in:
+21
-22
@@ -1,31 +1,30 @@
|
||||
export function ripple(node: HTMLElement) {
|
||||
function handleClick(event: MouseEvent) {
|
||||
const circle = document.createElement("span");
|
||||
function handleClick(event: MouseEvent) {
|
||||
const circle = document.createElement('span');
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
const rect = node.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
|
||||
circle.style.width = `${size}px`;
|
||||
circle.style.height = `${size}px`;
|
||||
circle.style.width = `${size}px`;
|
||||
circle.style.height = `${size}px`;
|
||||
|
||||
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
|
||||
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
|
||||
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
|
||||
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
|
||||
|
||||
circle.className =
|
||||
"absolute rounded-full bg-white/30 animate-ripple pointer-events-none";
|
||||
circle.className = 'absolute rounded-full bg-white/30 animate-ripple pointer-events-none';
|
||||
|
||||
node.appendChild(circle);
|
||||
node.appendChild(circle);
|
||||
|
||||
circle.addEventListener("animationend", () => {
|
||||
circle.remove();
|
||||
});
|
||||
}
|
||||
circle.addEventListener('animationend', () => {
|
||||
circle.remove();
|
||||
});
|
||||
}
|
||||
|
||||
node.addEventListener("click", handleClick);
|
||||
node.addEventListener('click', handleClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("click", handleClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('click', handleClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
onFilesRejected?: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
ariaLabel?: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
onFilesSelected,
|
||||
onFilesRejected,
|
||||
accept = '.pdf,application/pdf',
|
||||
multiple = false,
|
||||
ariaLabel = 'PDF-Dateien hinzufügen',
|
||||
label = 'Dateien hinzufügen',
|
||||
sublabel = 'PDF hier ablegen oder zum Auswählen klicken',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let isDragging = $state(false);
|
||||
let rejectionMessage = $state<string | null>(null);
|
||||
let dragDepth = 0;
|
||||
|
||||
const matchesAccept = (file: File) =>
|
||||
accept
|
||||
.split(',')
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.some((filter) => {
|
||||
if (filter.startsWith('.')) return file.name.toLowerCase().endsWith(filter);
|
||||
if (filter.endsWith('/*')) return file.type.toLowerCase().startsWith(filter.slice(0, -1));
|
||||
return file.type.toLowerCase() === filter;
|
||||
});
|
||||
|
||||
const processFiles = (files: FileList | null | undefined) => {
|
||||
if (!files) return;
|
||||
|
||||
const received = Array.from(files);
|
||||
const accepted = received.filter(matchesAccept);
|
||||
const rejected = received.filter((file) => !matchesAccept(file));
|
||||
|
||||
if (rejected.length > 0) {
|
||||
rejectionMessage = `${rejected.map((file) => file.name).join(', ')}: Dateityp nicht unterstützt.`;
|
||||
onFilesRejected?.(rejected);
|
||||
}
|
||||
if (accepted.length > 0) {
|
||||
rejectionMessage = null;
|
||||
onFilesSelected(multiple ? accepted : accepted.slice(0, 1));
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => fileInput?.click();
|
||||
|
||||
const handleFileSelection = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
processFiles(input.files);
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
dragDepth += 1;
|
||||
isDragging = true;
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0) isDragging = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragDepth = 0;
|
||||
isDragging = false;
|
||||
processFiles(event.dataTransfer?.files);
|
||||
};
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
class="hidden"
|
||||
type="file"
|
||||
{accept}
|
||||
{multiple}
|
||||
onchange={handleFileSelection}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-primary-200 bg-primary-50/50 px-6 py-10 text-center transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
|
||||
? 'border-primary-400 bg-primary-100/60'
|
||||
: ''} {className}"
|
||||
aria-label={ariaLabel}
|
||||
data-dropzone-ready={fileInput ? 'true' : 'false'}
|
||||
onclick={openFileDialog}
|
||||
ondragenter={handleDragEnter}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="flex flex-row items-baseline justify-center gap-2">
|
||||
<span class="text-[18px] font-bold text-primary-900">{label}</span>
|
||||
<span class="text-[20px] font-extrabold text-primary">+</span>
|
||||
</div>
|
||||
{#if sublabel}
|
||||
<p class="text-[13px] text-primary-700">{sublabel}</p>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if rejectionMessage}
|
||||
<p class="mt-2 text-sm font-medium text-red-700" role="alert">{rejectionMessage}</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { PdfThumbnail } from '$lib/pdf-thumbnails';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type Props = {
|
||||
thumbnails: PdfThumbnail[];
|
||||
selectedPages?: Set<number>;
|
||||
onPageClick?: (pageNumber: number) => void;
|
||||
thumbnailWidth?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
thumbnails,
|
||||
selectedPages = new Set<number>(),
|
||||
onPageClick,
|
||||
thumbnailWidth = 120,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('grid gap-3', className)}
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({thumbnailWidth}px, 1fr));"
|
||||
>
|
||||
{#each thumbnails as thumb}
|
||||
{@const isSelected = selectedPages.has(thumb.pageNumber)}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
|
||||
)}
|
||||
onclick={() => onPageClick?.(thumb.pageNumber)}
|
||||
>
|
||||
<img
|
||||
src={thumb.imageUrl}
|
||||
alt="Seite {thumb.pageNumber}"
|
||||
class="w-full rounded border border-primary-100 bg-white object-contain"
|
||||
style="aspect-ratio: {thumb.width} / {thumb.height};"
|
||||
/>
|
||||
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
|
||||
{#if isSelected}
|
||||
<div
|
||||
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -4,6 +4,7 @@
|
||||
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
||||
import {
|
||||
buildArchiveExport,
|
||||
buildSubDocumentExport,
|
||||
createSubDocument,
|
||||
dissolveSubDocument,
|
||||
moveAttachmentIntoSubDocument,
|
||||
@@ -41,8 +42,11 @@
|
||||
|
||||
let downloadMode = $state<ExportMode>('single');
|
||||
let isMerging = $state(false);
|
||||
let downloadingSubDocumentId = $state<string | null>(null);
|
||||
let mergeError = $state<string | null>(null);
|
||||
|
||||
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
|
||||
|
||||
$effect(() => {
|
||||
archiveName = archive.name;
|
||||
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
|
||||
@@ -293,7 +297,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMerging) {
|
||||
if (isExportActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -313,6 +317,29 @@
|
||||
isMerging = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSubDocument = async (subDocument: SubDocument) => {
|
||||
if (isExportActive || subDocument.attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadingSubDocumentId = subDocument.id;
|
||||
mergeError = null;
|
||||
|
||||
try {
|
||||
const exportUnit = await buildSubDocumentExport(archiveName, subDocument);
|
||||
|
||||
downloadPdfBytes(exportUnit.bytes, exportUnit.name);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
|
||||
error
|
||||
);
|
||||
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
|
||||
} finally {
|
||||
downloadingSubDocumentId = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
@@ -354,17 +381,21 @@
|
||||
|
||||
{#if hasSubDocuments}
|
||||
<div
|
||||
class="inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
|
||||
class={cn(
|
||||
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
|
||||
downloadingSubDocumentId !== null ? 'opacity-60' : ''
|
||||
)}
|
||||
role="radiogroup"
|
||||
aria-label="Export-Modus"
|
||||
>
|
||||
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
|
||||
<label
|
||||
class={cn(
|
||||
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
|
||||
'rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
|
||||
downloadMode === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-primary-700 hover:bg-primary-50'
|
||||
: 'text-primary-700 hover:bg-primary-50',
|
||||
downloadingSubDocumentId !== null ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -373,6 +404,7 @@
|
||||
name={`download-mode-${archive.name}`}
|
||||
value={option.value}
|
||||
checked={downloadMode === option.value}
|
||||
disabled={downloadingSubDocumentId !== null}
|
||||
onchange={() => {
|
||||
downloadMode = option.value as ExportMode;
|
||||
}}
|
||||
@@ -386,7 +418,7 @@
|
||||
<Button
|
||||
class="bg-primary"
|
||||
type="button"
|
||||
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isMerging}
|
||||
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isExportActive}
|
||||
onclick={downloadArchive}
|
||||
>
|
||||
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
||||
@@ -495,6 +527,9 @@
|
||||
onBlockDragOver={handleBlockDragOver}
|
||||
onBlockDrop={handleBlockDrop}
|
||||
onBlockDragEnd={handleBlockDragEnd}
|
||||
onDownload={() => downloadSubDocument(subDocument)}
|
||||
isDownloading={downloadingSubDocumentId === subDocument.id}
|
||||
downloadDisabled={isExportActive}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle } from '@lucide/svelte';
|
||||
import { isRasterizationTooLarge, shouldWarnAboutRasterization } from '$lib/rasterization-limits';
|
||||
|
||||
type Props = { file: File | null };
|
||||
let { file }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if shouldWarnAboutRasterization(file)}
|
||||
<div
|
||||
class="flex gap-3 rounded-lg border p-4 text-sm {isRasterizationTooLarge(file)
|
||||
? 'border-red-200 bg-red-50 text-red-800'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'}"
|
||||
role={isRasterizationTooLarge(file) ? 'alert' : undefined}
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<p>
|
||||
{isRasterizationTooLarge(file)
|
||||
? 'Diese Datei ist größer als 100 MB und kann aus Speichergründen nicht im Browser gerastert werden.'
|
||||
: 'Diese große Datei benötigt beim Rasterisieren viel Arbeitsspeicher. Schließen Sie andere speicherintensive Tabs.'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
@@ -16,6 +17,9 @@
|
||||
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
|
||||
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
|
||||
onBlockDragEnd?: () => void;
|
||||
onDownload?: () => void;
|
||||
isDownloading?: boolean;
|
||||
downloadDisabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -30,7 +34,10 @@
|
||||
onBlockDragStart = () => {},
|
||||
onBlockDragOver = () => {},
|
||||
onBlockDrop = () => {},
|
||||
onBlockDragEnd = () => {}
|
||||
onBlockDragEnd = () => {},
|
||||
onDownload,
|
||||
isDownloading = false,
|
||||
downloadDisabled = false
|
||||
}: Props = $props();
|
||||
|
||||
let subDocumentName = $state('');
|
||||
@@ -200,6 +207,19 @@
|
||||
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
|
||||
</span>
|
||||
|
||||
{#if onDownload}
|
||||
<Button
|
||||
type="button"
|
||||
variant="bordered"
|
||||
disabled={downloadDisabled || isDownloading || attachments.length === 0}
|
||||
onclick={onDownload}
|
||||
draggable="false"
|
||||
aria-label={`PDF für ${subDocumentName} herunterladen`}
|
||||
>
|
||||
{isDownloading ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-700 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
||||
type="button"
|
||||
|
||||
@@ -1,76 +1,20 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
class?: string;
|
||||
};
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
|
||||
let { onFilesSelected, class: className = "" }: Props = $props();
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let isDragging = $state(false);
|
||||
|
||||
const processFiles = (files: FileList | null | undefined) => {
|
||||
if (!files) return;
|
||||
|
||||
const filtered = Array.from(files).filter((file) => {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith('.zip') ||
|
||||
file.type === 'application/zip' ||
|
||||
file.type === 'application/x-zip-compressed'
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
onFilesSelected(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => fileInput?.click();
|
||||
|
||||
const handleFileSelection = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
processFiles(input.files);
|
||||
input.value = ''; // Reset so same file can be uploaded twice
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
processFiles(event.dataTransfer?.files);
|
||||
};
|
||||
let { onFilesSelected, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
multiple
|
||||
onchange={handleFileSelection}
|
||||
<FileDropzone
|
||||
{onFilesSelected}
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
multiple={true}
|
||||
ariaLabel="ZIP-Dateien hinzufügen"
|
||||
label="Dateien hinzufügen"
|
||||
sublabel="ZIP-Dateien hier ablegen oder zum Auswählen klicken"
|
||||
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 text-primary bg-transparent p-0 text-center appearance-none focus:outline-none transition-colors rounded-2xl {isDragging ? 'bg-gray-100 border-2 border-dashed border-primary-500' : ''} {className}"
|
||||
aria-label="ZIP-Dateien hinzufügen"
|
||||
onclick={openFileDialog}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="flex flex-row items-baseline justify-center gap-2">
|
||||
<span class="text-[20px] font-bold">Dateien hinzufügen</span>
|
||||
<span class="text-[20px] font-extrabold">+</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const downloadBlob = (
|
||||
bytes: Uint8Array,
|
||||
fileName: string,
|
||||
mimeType: string = 'application/pdf'
|
||||
) => {
|
||||
const buffer = new Uint8Array(bytes.byteLength);
|
||||
buffer.set(bytes);
|
||||
const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const downloadLink = document.createElement('a');
|
||||
|
||||
downloadLink.href = objectUrl;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.rel = 'noopener';
|
||||
downloadLink.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
};
|
||||
|
||||
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = blobUrl;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.rel = 'noopener';
|
||||
downloadLink.click();
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export const mergePdfFiles = async (files: File[]): Promise<Uint8Array> => {
|
||||
if (files.length < 2) throw new Error('At least two PDF files are required.');
|
||||
|
||||
const mergedDocument = await PDFDocument.create();
|
||||
for (const file of files) {
|
||||
const sourceDocument = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||
const copiedPages = await mergedDocument.copyPages(
|
||||
sourceDocument,
|
||||
sourceDocument.getPageIndices()
|
||||
);
|
||||
for (const page of copiedPages) mergedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return mergedDocument.save();
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
export type PdfThumbnail = {
|
||||
pageNumber: number;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type PdfDocumentHandle = {
|
||||
pageCount: number;
|
||||
thumbnails: PdfThumbnail[];
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
|
||||
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||
(module) => module.default
|
||||
);
|
||||
|
||||
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||
const thumbnails: PdfThumbnail[] = [];
|
||||
|
||||
try {
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||
const page = await pdfDocument.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
|
||||
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
|
||||
|
||||
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) =>
|
||||
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
|
||||
'image/png'
|
||||
);
|
||||
});
|
||||
|
||||
thumbnails.push({
|
||||
pageNumber,
|
||||
imageUrl: URL.createObjectURL(blob),
|
||||
width: viewport.width,
|
||||
height: viewport.height
|
||||
});
|
||||
}
|
||||
|
||||
let destroyed = false;
|
||||
return {
|
||||
pageCount: pdfDocument.numPages,
|
||||
thumbnails,
|
||||
destroy: () => {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
|
||||
void loadingTask.destroy();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
|
||||
await loadingTask.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
let pendingZipFiles: File[] = [];
|
||||
|
||||
export const queueZipFiles = (files: File[]) => {
|
||||
pendingZipFiles = [...pendingZipFiles, ...files];
|
||||
};
|
||||
|
||||
export const takeQueuedZipFiles = () => {
|
||||
const files = pendingZipFiles;
|
||||
pendingZipFiles = [];
|
||||
return files;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export const RASTERIZATION_WARNING_BYTES = 25 * 1024 * 1024;
|
||||
export const RASTERIZATION_MAX_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export const isRasterizationTooLarge = (file: File | null) =>
|
||||
file !== null && file.size > RASTERIZATION_MAX_BYTES;
|
||||
|
||||
export const shouldWarnAboutRasterization = (file: File | null) =>
|
||||
file !== null && file.size > RASTERIZATION_WARNING_BYTES;
|
||||
@@ -0,0 +1,51 @@
|
||||
export type Tool = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export const tools: Tool[] = [
|
||||
{
|
||||
slug: 'watermark',
|
||||
title: 'Wasserzeichen',
|
||||
description: 'Fügen Sie ein Wasserzeichen zu Ihrem PDF hinzu.',
|
||||
icon: 'droplets'
|
||||
},
|
||||
{
|
||||
slug: 'encrypt',
|
||||
title: 'Passwort setzen',
|
||||
description: 'Verschlüsseln Sie Ihr PDF mit einem Passwort.',
|
||||
icon: 'lock'
|
||||
},
|
||||
{
|
||||
slug: 'decrypt',
|
||||
title: 'Passwort entfernen',
|
||||
description: 'Entsperren Sie ein passwortgeschütztes PDF.',
|
||||
icon: 'unlock'
|
||||
},
|
||||
{
|
||||
slug: 'convert',
|
||||
title: 'Konvertieren',
|
||||
description: 'Konvertieren Sie ein PDF in Bilder.',
|
||||
icon: 'image'
|
||||
},
|
||||
{
|
||||
slug: 'compress',
|
||||
title: 'Komprimieren',
|
||||
description: 'Komprimieren Sie ein PDF für kleinere Dateigröße.',
|
||||
icon: 'minimize'
|
||||
},
|
||||
{
|
||||
slug: 'separate',
|
||||
title: 'Seiten entfernen',
|
||||
description: 'Entfernen Sie Seiten aus einem PDF.',
|
||||
icon: 'scissors'
|
||||
},
|
||||
{
|
||||
slug: 'merge',
|
||||
title: 'PDFs zusammenfügen',
|
||||
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
|
||||
icon: 'merge'
|
||||
}
|
||||
];
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
|
||||
@@ -572,6 +572,25 @@ export const dissolveSubDocument = (
|
||||
const stripPdfExtension = (name: string) =>
|
||||
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
|
||||
|
||||
/**
|
||||
* Build a single PDF export unit for one sub-document. The resulting filename
|
||||
* follows the same convention as the separate archive export:
|
||||
* `{archiveName} - {subDocumentName}` (without duplicate `.pdf` suffixes).
|
||||
*/
|
||||
export const buildSubDocumentExport = async (
|
||||
archiveName: string,
|
||||
subDocument: SubDocument
|
||||
): Promise<ExportUnit> => {
|
||||
if (subDocument.attachments.length === 0) {
|
||||
throw new Error('Cannot create a PDF from an empty sub-document.');
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${stripPdfExtension(archiveName)} - ${stripPdfExtension(subDocument.name)}`,
|
||||
bytes: await mergeAttachments(subDocument.attachments)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build one or more PDF export units from the archive.
|
||||
*
|
||||
@@ -607,10 +626,7 @@ export const buildArchiveExport = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
separateUnits.push({
|
||||
name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
|
||||
bytes: await mergeAttachments(subDocument.attachments)
|
||||
});
|
||||
separateUnits.push(await buildSubDocumentExport(archive.name, subDocument));
|
||||
}
|
||||
|
||||
if (archive.attachments.length > 0) {
|
||||
|
||||
+132
-194
@@ -1,211 +1,149 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from 'svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
|
||||
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
|
||||
import {
|
||||
extractZipArchives,
|
||||
mergeProcessedZipArchive,
|
||||
type ProcessedZipArchive
|
||||
} from '$lib/zip-processing';
|
||||
import * as Card from "$lib/components/ui/card/index";
|
||||
import {
|
||||
Archive,
|
||||
Droplets,
|
||||
Lock,
|
||||
Unlock,
|
||||
Image,
|
||||
Minimize2,
|
||||
Scissors,
|
||||
Merge,
|
||||
FileText
|
||||
} from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import * as Card from '$lib/components/ui/card/index';
|
||||
import { queueZipFiles } from '$lib/pending-files';
|
||||
import { tools } from '$lib/tools';
|
||||
|
||||
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
||||
const iconMap: Record<string, typeof FileText> = {
|
||||
droplets: Droplets,
|
||||
lock: Lock,
|
||||
unlock: Unlock,
|
||||
image: Image,
|
||||
minimize: Minimize2,
|
||||
scissors: Scissors,
|
||||
merge: Merge
|
||||
};
|
||||
|
||||
let selectedZipFiles: File[] = $state([]);
|
||||
let processedZipFiles: ProcessedZipArchive[] = $state([]);
|
||||
let pendingZipCount = $state(0);
|
||||
let thumbnailWidth = $state(200);
|
||||
let thumbnailWidthHydrated = $state(false);
|
||||
let archiveGeneration = 0;
|
||||
let isDraggingFiles = $state(false);
|
||||
|
||||
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
|
||||
const isZipFile = (file: File) =>
|
||||
file.name.toLowerCase().endsWith('.zip') ||
|
||||
file.type === 'application/zip' ||
|
||||
file.type === 'application/x-zip-compressed';
|
||||
|
||||
onMount(() => {
|
||||
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
|
||||
const openBeaFiles = (files: File[]) => {
|
||||
const zipFiles = files.filter(isZipFile);
|
||||
if (zipFiles.length === 0) return;
|
||||
|
||||
if (storedValue !== null) {
|
||||
const parsedValue = Number(storedValue);
|
||||
queueZipFiles(zipFiles);
|
||||
void goto('/tools/bea');
|
||||
};
|
||||
|
||||
if (Number.isFinite(parsedValue)) {
|
||||
thumbnailWidth = clampThumbnailWidth(parsedValue);
|
||||
}
|
||||
}
|
||||
const handleWindowDragOver = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||
event.preventDefault();
|
||||
isDraggingFiles = true;
|
||||
};
|
||||
|
||||
thumbnailWidthHydrated = true;
|
||||
});
|
||||
const handleWindowDragLeave = (event: DragEvent) => {
|
||||
if (event.relatedTarget === null) isDraggingFiles = false;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!thumbnailWidthHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth));
|
||||
});
|
||||
|
||||
const toArrayBuffer = (bytes: Uint8Array) => {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
};
|
||||
|
||||
const downloadPdfBytes = (bytes: Uint8Array, fileName: string) => {
|
||||
const pdfBlob = new Blob([toArrayBuffer(bytes)], {type: 'application/pdf'});
|
||||
const objectUrl = URL.createObjectURL(pdfBlob);
|
||||
const downloadLink = document.createElement('a');
|
||||
|
||||
downloadLink.href = objectUrl;
|
||||
downloadLink.download = fileName.toLowerCase().endsWith('.pdf') ? fileName : `${fileName}.pdf`;
|
||||
downloadLink.rel = 'noopener';
|
||||
downloadLink.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
};
|
||||
|
||||
const handleFilesSelected = (files: File[]) => {
|
||||
selectedZipFiles = [...selectedZipFiles, ...files];
|
||||
|
||||
for (const file of files) {
|
||||
void processZipFile(file, archiveGeneration);
|
||||
}
|
||||
};
|
||||
|
||||
const processZipFile = async (file: File, generation: number) => {
|
||||
pendingZipCount += 1;
|
||||
|
||||
try {
|
||||
const processedZipArchives = await extractZipArchives(file);
|
||||
|
||||
if (generation !== archiveGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
|
||||
} catch (error) {
|
||||
console.error(`Failed to process ${file.name}`, error);
|
||||
} finally {
|
||||
if (generation === archiveGeneration) {
|
||||
pendingZipCount -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
|
||||
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
|
||||
existingIndex === index ? archive : existingArchive
|
||||
);
|
||||
};
|
||||
|
||||
const exportAllZipArchivesAsPdf = async () => {
|
||||
for (const archive of processedZipFiles) {
|
||||
try {
|
||||
const mergedBytes = await mergeProcessedZipArchive(archive);
|
||||
downloadPdfBytes(mergedBytes, archive.name);
|
||||
} catch (error) {
|
||||
console.error(`Failed to export archive ${archive.name}`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllZipArchives = () => {
|
||||
archiveGeneration += 1;
|
||||
selectedZipFiles = [];
|
||||
processedZipFiles = [];
|
||||
pendingZipCount = 0;
|
||||
};
|
||||
const handleWindowDrop = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
event.preventDefault();
|
||||
isDraggingFiles = false;
|
||||
openBeaFiles(Array.from(event.dataTransfer.files));
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet content()}
|
||||
{#if selectedZipFiles.length === 0}
|
||||
<Card.Root>
|
||||
<Card.Content>
|
||||
<ZipDropzone onFilesSelected={handleFilesSelected}/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<details class="fixed bottom-4 right-4 z-20">
|
||||
<summary
|
||||
class="flex h-12 w-12 cursor-pointer list-none items-center justify-center rounded-full border border-primary-200 bg-white text-lg font-semibold text-primary-800 shadow-lg transition hover:border-primary-300 hover:bg-primary-50"
|
||||
>
|
||||
<span class="sr-only">Settings</span>
|
||||
<span>⚙️</span>
|
||||
</summary>
|
||||
<svelte:head>
|
||||
<title>juri-merger — PDF-Werkzeuge</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
type="button"
|
||||
disabled={processedZipFiles.length === 0}
|
||||
onclick={exportAllZipArchivesAsPdf}
|
||||
>
|
||||
Alle ZIP-Archive als PDF exportieren
|
||||
</Button>
|
||||
<svelte:window
|
||||
ondragover={handleWindowDragOver}
|
||||
ondragleave={handleWindowDragLeave}
|
||||
ondrop={handleWindowDrop}
|
||||
/>
|
||||
|
||||
<Button
|
||||
class="flex-1"
|
||||
type="button"
|
||||
disabled={selectedZipFiles.length === 0}
|
||||
variant="secondary"
|
||||
onclick={deleteAllZipArchives}
|
||||
>
|
||||
Alle ZIP-Archive löschen
|
||||
</Button>
|
||||
</div>
|
||||
{#if isDraggingFiles}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-3 z-50 grid place-items-center rounded-3xl border-4 border-dashed border-primary bg-background/95 text-center shadow-2xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div>
|
||||
<Archive class="mx-auto h-12 w-12" />
|
||||
<p class="mt-4 text-xl font-bold">ZIP-Archiv hier ablegen</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900"
|
||||
>
|
||||
<span>Thumbnail Breite</span>
|
||||
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-10 px-4 py-10">
|
||||
<!-- Hero -->
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<h1 class="text-[28px] font-bold text-primary-900">PDF-Werkzeuge</h1>
|
||||
<p class="max-w-lg text-[15px] text-primary-700">
|
||||
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="w-full accent-primary-700"
|
||||
type="range"
|
||||
min="100"
|
||||
max="400"
|
||||
step="1"
|
||||
bind:value={thumbnailWidth}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"
|
||||
>
|
||||
<div class="mx-auto flex w-full flex-col gap-3">
|
||||
{#if pendingZipCount > 0}
|
||||
<div
|
||||
class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"
|
||||
>
|
||||
Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Hero tile: beA workflow -->
|
||||
<a href="/tools/bea" class="block w-full max-w-2xl mx-auto">
|
||||
<Card.Root class="transition hover:shadow-md cursor-pointer">
|
||||
<Card.Content class="flex flex-col items-center gap-4 py-10">
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
|
||||
>
|
||||
<Archive class="h-8 w-8" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h2 class="text-[18px] font-bold text-primary-900">ZIP-Archiv bearbeiten</h2>
|
||||
<p class="mt-1 text-[14px] text-primary-700">
|
||||
beA-Archive verarbeiten, Teildokumente erstellen und als PDF exportieren.
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
|
||||
{#if processedZipFiles.length > 0}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each processedZipFiles as file, index}
|
||||
<ProcessedZipArchiveEditor
|
||||
archive={file}
|
||||
{thumbnailWidth}
|
||||
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<!-- ZIP drop entry point -->
|
||||
<div class="mx-auto w-full max-w-2xl">
|
||||
<FileDropzone
|
||||
onFilesSelected={openBeaFiles}
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
multiple={true}
|
||||
ariaLabel="ZIP-Dateien hinzufügen"
|
||||
label="ZIP-Datei hier ablegen"
|
||||
sublabel="oder zum Auswählen klicken"
|
||||
class="py-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="px-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 py-6">
|
||||
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
|
||||
</div>
|
||||
{@render content()}
|
||||
</section>
|
||||
<!-- Tool grid -->
|
||||
<div class="mx-auto w-full max-w-4xl">
|
||||
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Werkzeuge</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each tools as tool}
|
||||
{@const Icon = iconMap[tool.icon] ?? FileText}
|
||||
<a href="/tools/{tool.slug}" class="block">
|
||||
<Card.Root class="h-full transition hover:shadow-md cursor-pointer">
|
||||
<Card.Content class="flex flex-col gap-3 py-6">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
|
||||
>
|
||||
<Icon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3>
|
||||
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+9
-11
@@ -1,8 +1,8 @@
|
||||
@import 'tailwindcss';
|
||||
/*@import '../globals.css';*/
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn-svelte/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn-svelte/tailwind.css';
|
||||
@import '@fontsource-variable/geist';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #EEF4F6;
|
||||
--foreground: #122F62;
|
||||
--background: #eef4f6;
|
||||
--foreground: #122f62;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: #122f62;
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: #EA544B;
|
||||
--secondary-end: #FF6F6D;
|
||||
--secondary: #ea544b;
|
||||
--secondary-end: #ff6f6d;
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
@@ -54,14 +54,14 @@
|
||||
|
||||
.dark {
|
||||
--background: #212121;
|
||||
--foreground: #FFFFFF;
|
||||
--foreground: #ffffff;
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: #92b2eb;
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: linear-gradient(90deg, #EA544B 0%, #FF6F6D 100%);
|
||||
--secondary: linear-gradient(90deg, #ea544b 0%, #ff6f6d 100%);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
@@ -140,7 +140,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ripple */
|
||||
|
||||
@keyframes ripple {
|
||||
@@ -158,4 +157,3 @@
|
||||
.animate-ripple {
|
||||
animation: ripple 600ms ease-out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft } from '@lucide/svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<section class="px-4">
|
||||
<div class="flex flex-wrap items-center gap-3 py-6">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 text-[14px] font-medium text-primary-700 transition hover:text-primary-900"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Zurück
|
||||
</a>
|
||||
</div>
|
||||
{@render children()}
|
||||
</section>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
|
||||
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
|
||||
import {
|
||||
extractZipArchives,
|
||||
mergeProcessedZipArchive,
|
||||
type ProcessedZipArchive
|
||||
} from '$lib/zip-processing';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { takeQueuedZipFiles } from '$lib/pending-files';
|
||||
import * as Card from '$lib/components/ui/card/index';
|
||||
|
||||
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
||||
|
||||
let selectedZipFiles: File[] = $state([]);
|
||||
let processedZipFiles: ProcessedZipArchive[] = $state([]);
|
||||
let pendingZipCount = $state(0);
|
||||
let thumbnailWidth = $state(200);
|
||||
let thumbnailWidthHydrated = $state(false);
|
||||
let archiveGeneration = 0;
|
||||
|
||||
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
|
||||
|
||||
onMount(() => {
|
||||
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
|
||||
|
||||
if (storedValue !== null) {
|
||||
const parsedValue = Number(storedValue);
|
||||
|
||||
if (Number.isFinite(parsedValue)) {
|
||||
thumbnailWidth = clampThumbnailWidth(parsedValue);
|
||||
}
|
||||
}
|
||||
|
||||
thumbnailWidthHydrated = true;
|
||||
|
||||
const queuedFiles = takeQueuedZipFiles();
|
||||
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!thumbnailWidthHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth));
|
||||
});
|
||||
|
||||
const handleFilesSelected = (files: File[]) => {
|
||||
selectedZipFiles = [...selectedZipFiles, ...files];
|
||||
|
||||
for (const file of files) {
|
||||
void processZipFile(file, archiveGeneration);
|
||||
}
|
||||
};
|
||||
|
||||
const processZipFile = async (file: File, generation: number) => {
|
||||
pendingZipCount += 1;
|
||||
|
||||
try {
|
||||
const processedZipArchives = await extractZipArchives(file);
|
||||
|
||||
if (generation !== archiveGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
|
||||
} catch (error) {
|
||||
console.error(`Failed to process ${file.name}`, error);
|
||||
} finally {
|
||||
if (generation === archiveGeneration) {
|
||||
pendingZipCount -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
|
||||
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
|
||||
existingIndex === index ? archive : existingArchive
|
||||
);
|
||||
};
|
||||
|
||||
const exportAllZipArchivesAsPdf = async () => {
|
||||
for (const archive of processedZipFiles) {
|
||||
try {
|
||||
const mergedBytes = await mergeProcessedZipArchive(archive);
|
||||
downloadBlob(mergedBytes, archive.name);
|
||||
} catch (error) {
|
||||
console.error(`Failed to export archive ${archive.name}`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllZipArchives = () => {
|
||||
archiveGeneration += 1;
|
||||
selectedZipFiles = [];
|
||||
processedZipFiles = [];
|
||||
pendingZipCount = 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet content()}
|
||||
{#if selectedZipFiles.length === 0}
|
||||
<Card.Root>
|
||||
<Card.Content>
|
||||
<ZipDropzone onFilesSelected={handleFilesSelected} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<details class="fixed bottom-4 right-4 z-20">
|
||||
<summary
|
||||
class="flex h-12 w-12 cursor-pointer list-none items-center justify-center rounded-full border border-primary-200 bg-white text-lg font-semibold text-primary-800 shadow-lg transition hover:border-primary-300 hover:bg-primary-50"
|
||||
>
|
||||
<span class="sr-only">Settings</span>
|
||||
<span>⚙️</span>
|
||||
</summary>
|
||||
|
||||
<div
|
||||
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
type="button"
|
||||
disabled={processedZipFiles.length === 0}
|
||||
onclick={exportAllZipArchivesAsPdf}
|
||||
>
|
||||
Alle ZIP-Archive als PDF exportieren
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
class="flex-1"
|
||||
type="button"
|
||||
disabled={selectedZipFiles.length === 0}
|
||||
variant="secondary"
|
||||
onclick={deleteAllZipArchives}
|
||||
>
|
||||
Alle ZIP-Archive löschen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900"
|
||||
>
|
||||
<span>Thumbnail Breite</span>
|
||||
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="w-full accent-primary-700"
|
||||
type="range"
|
||||
min="100"
|
||||
max="400"
|
||||
step="1"
|
||||
bind:value={thumbnailWidth}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"
|
||||
>
|
||||
<div class="mx-auto flex w-full flex-col gap-3">
|
||||
{#if pendingZipCount > 0}
|
||||
<div
|
||||
class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"
|
||||
>
|
||||
Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if processedZipFiles.length > 0}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each processedZipFiles as file, index}
|
||||
<ProcessedZipArchiveEditor
|
||||
archive={file}
|
||||
{thumbnailWidth}
|
||||
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div>
|
||||
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
|
||||
{@render content()}
|
||||
</div>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
||||
import { AlertTriangle } from '@lucide/svelte';
|
||||
|
||||
type CompressionPreset = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
scale: number;
|
||||
quality: number;
|
||||
};
|
||||
|
||||
const presets: CompressionPreset[] = [
|
||||
{
|
||||
id: 'strong',
|
||||
label: 'Starke Komprimierung',
|
||||
description: '~72 DPI, JPEG 50%',
|
||||
scale: 1,
|
||||
quality: 0.5
|
||||
},
|
||||
{
|
||||
id: 'balanced',
|
||||
label: 'Ausgewogen',
|
||||
description: '~108 DPI, JPEG 70%',
|
||||
scale: 1.5,
|
||||
quality: 0.7
|
||||
},
|
||||
{ id: 'light', label: 'Leicht', description: '~144 DPI, JPEG 85%', scale: 2, quality: 0.85 }
|
||||
];
|
||||
|
||||
let pdfFile: File | null = $state(null);
|
||||
let selectedPreset = $state('balanced');
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let compressedBytes = $state<Uint8Array | null>(null);
|
||||
let compressedFileName = $state('');
|
||||
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
|
||||
|
||||
const handleFileSelected = (files: File[]) => {
|
||||
pdfFile = files[0] ?? null;
|
||||
error = null;
|
||||
compressedBytes = null;
|
||||
compressedFileName = '';
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const compressPdf = async () => {
|
||||
if (!pdfFile) return;
|
||||
|
||||
const preset = presets.find((p) => p.id === selectedPreset) ?? presets[1];
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
compressedBytes = null;
|
||||
|
||||
try {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
|
||||
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||
(m) => m.default
|
||||
);
|
||||
|
||||
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
||||
const loadingTask = getDocument({ data: bytes });
|
||||
|
||||
try {
|
||||
const pdfjsDoc = await loadingTask.promise;
|
||||
const outputPdf = await PDFDocument.create();
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdfjsDoc.numPages; pageNumber += 1) {
|
||||
const page = await pdfjsDoc.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale: preset.scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
if (!canvasContext) throw new Error('Could not create canvas context');
|
||||
|
||||
await page.render({
|
||||
canvas,
|
||||
canvasContext,
|
||||
viewport,
|
||||
background: '#ffffff'
|
||||
}).promise;
|
||||
|
||||
const jpegBlob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error('JPEG encode failed'))),
|
||||
'image/jpeg',
|
||||
preset.quality
|
||||
);
|
||||
});
|
||||
const jpegImage = await outputPdf.embedJpg(new Uint8Array(await jpegBlob.arrayBuffer()));
|
||||
const originalViewport = page.getViewport({ scale: 1 });
|
||||
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
|
||||
pdfPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfPage.getWidth(),
|
||||
height: pdfPage.getHeight()
|
||||
});
|
||||
}
|
||||
|
||||
compressedBytes = await outputPdf.save();
|
||||
compressedFileName = `${pdfFile.name.replace(/\.pdf$/i, '')}_komprimiert.pdf`;
|
||||
} finally {
|
||||
await loadingTask.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Compression failed:', err);
|
||||
error = 'Fehler beim Komprimieren des PDFs.';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCompressedPdf = () => {
|
||||
if (compressedBytes) downloadBlob(compressedBytes, compressedFileName);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Komprimieren</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">
|
||||
Reduzieren Sie die Dateigröße Ihres PDFs durch Komprimierung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
||||
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
||||
<div class="text-sm text-amber-800">
|
||||
<p class="font-medium">Hinweis zur Qualität</p>
|
||||
<p class="mt-1">
|
||||
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
|
||||
Durchsuchbarkeit und Vektorgrafiken gehen verloren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !pdfFile}
|
||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFileSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={pdfFile.name}
|
||||
class="py-4"
|
||||
/>
|
||||
<RasterizationWarning file={pdfFile} />
|
||||
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-primary-900">{pdfFile.name}</h2>
|
||||
<p class="text-sm text-primary-700">Dateigröße: {formatBytes(pdfFile.size)}</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-primary-900">Komprimierungsstufe</p>
|
||||
{#each presets as preset}
|
||||
<label
|
||||
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
|
||||
preset.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-primary-200 hover:border-primary-300'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="compression-preset"
|
||||
value={preset.id}
|
||||
bind:group={selectedPreset}
|
||||
onchange={() => {
|
||||
compressedBytes = null;
|
||||
compressedFileName = '';
|
||||
}}
|
||||
class="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary-900">{preset.label}</p>
|
||||
<p class="text-xs text-primary-700">{preset.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if compressedBytes}
|
||||
<p class="text-sm text-primary-700">
|
||||
Ergebnisgröße: <span class="font-semibold">{formatBytes(compressedBytes.length)}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button onclick={compressPdf} disabled={isProcessing || rasterizationBlocked}>
|
||||
{isProcessing ? 'Wird komprimiert...' : 'Komprimieren'}
|
||||
</Button>
|
||||
{#if compressedBytes}
|
||||
<Button onclick={downloadCompressedPdf}>PDF herunterladen</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob, downloadBlobUrl } from '$lib/download';
|
||||
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
||||
import { zipSync } from 'fflate';
|
||||
import { Image } from '@lucide/svelte';
|
||||
|
||||
type OutputFormat = 'png' | 'jpeg';
|
||||
|
||||
let pdfFile: File | null = $state(null);
|
||||
let outputFormat = $state<OutputFormat>('png');
|
||||
let scale = $state(2);
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let progress = $state(0);
|
||||
let totalPages = $state(0);
|
||||
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
|
||||
|
||||
const handleFileSelected = (files: File[]) => {
|
||||
pdfFile = files[0] ?? null;
|
||||
error = null;
|
||||
progress = 0;
|
||||
totalPages = 0;
|
||||
};
|
||||
|
||||
const convertPdf = async () => {
|
||||
if (!pdfFile) return;
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
progress = 0;
|
||||
|
||||
try {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||
(module) => module.default
|
||||
);
|
||||
|
||||
const loadingTask = getDocument({
|
||||
data: new Uint8Array(await pdfFile.arrayBuffer())
|
||||
});
|
||||
|
||||
try {
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
const renderScale = Number(scale);
|
||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||
const mimeType = outputFormat === 'png' ? 'image/png' : 'image/jpeg';
|
||||
const extension = outputFormat === 'png' ? 'png' : 'jpg';
|
||||
totalPages = pdfDocument.numPages;
|
||||
|
||||
const renderPage = async (pageNumber: number) => {
|
||||
const page = await pdfDocument.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale: renderScale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
if (!canvasContext) throw new Error('Could not create canvas context');
|
||||
|
||||
await page.render({ canvas, canvasContext, viewport, background: '#ffffff' }).promise;
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error('Image encode failed'))),
|
||||
mimeType,
|
||||
outputFormat === 'jpeg' ? 0.9 : undefined
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (pdfDocument.numPages === 1) {
|
||||
const blob = await renderPage(1);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
downloadBlobUrl(objectUrl, `${baseName}.${extension}`);
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
progress = 1;
|
||||
} else {
|
||||
const images: Record<string, Uint8Array> = {};
|
||||
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||
const blob = await renderPage(pageNumber);
|
||||
images[`seite_${String(pageNumber).padStart(3, '0')}.${extension}`] = new Uint8Array(
|
||||
await blob.arrayBuffer()
|
||||
);
|
||||
progress = pageNumber;
|
||||
}
|
||||
downloadBlob(zipSync(images), `${baseName}_bilder.zip`, 'application/zip');
|
||||
}
|
||||
} finally {
|
||||
await loadingTask.destroy();
|
||||
}
|
||||
} catch (conversionError) {
|
||||
console.error('Conversion failed:', conversionError);
|
||||
error = 'Fehler beim Konvertieren des PDFs.';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Konvertieren</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
|
||||
</div>
|
||||
|
||||
{#if !pdfFile}
|
||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFileSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={pdfFile.name}
|
||||
class="py-4"
|
||||
/>
|
||||
<RasterizationWarning file={pdfFile} />
|
||||
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
|
||||
<div class="flex items-center gap-2 text-primary-900">
|
||||
<Image class="h-5 w-5" />
|
||||
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary-900 mb-2">Bildformat</p>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
|
||||
<span class="text-sm text-primary-700">PNG (verlustfrei)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
|
||||
<span class="text-sm text-primary-700">JPEG (kleiner)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary-900 mb-1" for="render-scale">
|
||||
Auflösung: {scale}x
|
||||
</label>
|
||||
<input
|
||||
id="render-scale"
|
||||
type="range"
|
||||
min="1"
|
||||
max="4"
|
||||
step="0.5"
|
||||
bind:value={scale}
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-primary-600 mt-1">
|
||||
{scale === 1
|
||||
? '~72 DPI'
|
||||
: scale === 2
|
||||
? '~150 DPI'
|
||||
: scale === 3
|
||||
? '~216 DPI'
|
||||
: '~288 DPI'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isProcessing && totalPages > 0}
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between text-xs text-primary-700">
|
||||
<span>Wird konvertiert...</span>
|
||||
<span>{progress} / {totalPages}</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-primary-100">
|
||||
<div
|
||||
class="h-2 rounded-full bg-primary transition-all"
|
||||
style="width: {(progress / totalPages) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
|
||||
{isProcessing ? 'Wird konvertiert...' : 'Konvertieren & herunterladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
||||
import { Unlock, AlertTriangle } from '@lucide/svelte';
|
||||
|
||||
let pdfFile: File | null = $state(null);
|
||||
let password = $state('');
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
|
||||
|
||||
const handleFileSelected = (files: File[]) => {
|
||||
pdfFile = files[0] ?? null;
|
||||
error = null;
|
||||
};
|
||||
|
||||
const decryptPdf = async () => {
|
||||
if (!pdfFile) return;
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||
(module) => module.default
|
||||
);
|
||||
|
||||
const loadingTask = getDocument({
|
||||
data: new Uint8Array(await pdfFile.arrayBuffer()),
|
||||
password
|
||||
});
|
||||
|
||||
try {
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
const outputPdf = await PDFDocument.create();
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||
const page = await pdfDocument.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale: 2 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
if (!canvasContext) throw new Error('Could not create canvas context');
|
||||
|
||||
await page.render({
|
||||
canvas,
|
||||
canvasContext,
|
||||
viewport,
|
||||
background: '#ffffff'
|
||||
}).promise;
|
||||
const pngBlob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error('PNG encode failed'))),
|
||||
'image/png'
|
||||
);
|
||||
});
|
||||
const pngImage = await outputPdf.embedPng(new Uint8Array(await pngBlob.arrayBuffer()));
|
||||
const originalViewport = page.getViewport({ scale: 1 });
|
||||
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
|
||||
pdfPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfPage.getWidth(),
|
||||
height: pdfPage.getHeight()
|
||||
});
|
||||
}
|
||||
|
||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||
downloadBlob(await outputPdf.save(), `${baseName}_entsperrt.pdf`);
|
||||
} finally {
|
||||
await loadingTask.destroy();
|
||||
}
|
||||
} catch (decryptionError) {
|
||||
console.error('Decryption failed:', decryptionError);
|
||||
error = 'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Passwort entfernen</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">
|
||||
Entfernen Sie den Passwortschutz von einem PDF-Dokument.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
||||
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
||||
<div class="text-sm text-amber-800">
|
||||
<p class="font-medium">Hinweis zur Qualität</p>
|
||||
<p class="mt-1">
|
||||
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
|
||||
Durchsuchbarkeit und Vektorgrafiken gehen verloren. Die Dateigröße kann größer werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !pdfFile}
|
||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFileSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={pdfFile.name}
|
||||
class="py-4"
|
||||
/>
|
||||
<RasterizationWarning file={pdfFile} />
|
||||
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
|
||||
<div class="flex items-center gap-2 text-primary-900">
|
||||
<Unlock class="h-5 w-5" />
|
||||
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary-900 mb-1" for="decrypt-password">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="decrypt-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="Passwort des PDFs eingeben"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button onclick={decryptPdf} disabled={isProcessing || !password || rasterizationBlocked}>
|
||||
{isProcessing ? 'Wird entsperrt...' : 'Passwort entfernen & herunterladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { Lock } from '@lucide/svelte';
|
||||
|
||||
let pdfFile: File | null = $state(null);
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let allowPrinting = $state(true);
|
||||
let allowCopying = $state(true);
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const handleFileSelected = (files: File[]) => {
|
||||
pdfFile = files[0] ?? null;
|
||||
error = null;
|
||||
};
|
||||
|
||||
const encryptPdf = async () => {
|
||||
if (!pdfFile || !password) return;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwörter stimmen nicht überein.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 1) {
|
||||
error = 'Bitte geben Sie ein Passwort ein.';
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
||||
const pdfDoc = await PDFDocument.load(bytes);
|
||||
|
||||
pdfDoc.encrypt({
|
||||
userPassword: password,
|
||||
ownerPassword: crypto.randomUUID(),
|
||||
permissions: {
|
||||
printing: allowPrinting,
|
||||
copying: allowCopying
|
||||
}
|
||||
});
|
||||
|
||||
const encryptedBytes = await pdfDoc.save();
|
||||
|
||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||
downloadBlob(encryptedBytes, `${baseName}_geschuetzt.pdf`);
|
||||
} catch (err) {
|
||||
console.error('Encryption failed:', err);
|
||||
error = 'Fehler beim Verschlüsseln des PDFs.';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Passwort setzen</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">
|
||||
Verschlüsseln Sie Ihr PDF mit einem Passwort, um es vor unbefugtem Zugriff zu schützen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !pdfFile}
|
||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFileSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={pdfFile.name}
|
||||
class="py-4"
|
||||
/>
|
||||
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
|
||||
<div class="flex items-center gap-2 text-primary-900">
|
||||
<Lock class="h-5 w-5" />
|
||||
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary-900 mb-1" for="encrypt-password">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="encrypt-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="Passwort eingeben"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-primary-900 mb-1"
|
||||
for="encrypt-password-confirmation"
|
||||
>
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<input
|
||||
id="encrypt-password-confirmation"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="Passwort wiederholen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-primary-900">Berechtigungen</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allow-printing"
|
||||
bind:checked={allowPrinting}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<label for="allow-printing" class="text-sm text-primary-700">Drucken erlauben</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="allow-copying" bind:checked={allowCopying} class="h-4 w-4" />
|
||||
<label for="allow-copying" class="text-sm text-primary-700">Kopieren erlauben</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
onclick={encryptPdf}
|
||||
disabled={isProcessing || !password || password !== confirmPassword}
|
||||
>
|
||||
{isProcessing ? 'Wird verschlüsselt...' : 'Passwort setzen & herunterladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { mergePdfFiles } from '$lib/pdf-processing';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
|
||||
let pdfFiles: File[] = $state([]);
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const handleFilesSelected = (files: File[]) => {
|
||||
pdfFiles = [...pdfFiles, ...files];
|
||||
error = null;
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
pdfFiles = pdfFiles.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const moveFile = (fromIndex: number, toIndex: number) => {
|
||||
if (toIndex < 0 || toIndex >= pdfFiles.length) return;
|
||||
const newFiles = [...pdfFiles];
|
||||
const [moved] = newFiles.splice(fromIndex, 1);
|
||||
newFiles.splice(toIndex, 0, moved);
|
||||
pdfFiles = newFiles;
|
||||
};
|
||||
|
||||
const mergePdfs = async () => {
|
||||
if (pdfFiles.length < 2) return;
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
downloadBlob(await mergePdfFiles(pdfFiles), 'zusammengefuegt.pdf');
|
||||
} catch (err) {
|
||||
console.error('Merge failed:', err);
|
||||
error = 'Fehler beim Zusammenfügen der PDFs.';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">
|
||||
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FileDropzone onFilesSelected={handleFilesSelected} multiple={true} />
|
||||
|
||||
{#if pdfFiles.length > 0}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-primary-900">
|
||||
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
|
||||
</h2>
|
||||
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
|
||||
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each pdfFiles as file, index}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-primary-200 bg-white p-3">
|
||||
<span
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span class="flex-1 truncate text-sm text-primary-900">{file.name}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={() => moveFile(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={() => moveFile(index, index + 1)}
|
||||
disabled={index === pdfFiles.length - 1}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => removeFile(index)}>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||
|
||||
let pdfFile: File | null = $state(null);
|
||||
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||
let selectedPages = $state(new Set<number>());
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let loadingError = $state<string | null>(null);
|
||||
|
||||
const handleFileSelected = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
pdfHandle?.destroy();
|
||||
pdfHandle = null;
|
||||
pdfFile = file;
|
||||
selectedPages = new Set();
|
||||
loadingError = null;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
pdfHandle = await loadPdfDocument(bytes);
|
||||
} catch (err) {
|
||||
console.error('Failed to load PDF:', err);
|
||||
loadingError = 'PDF konnte nicht geladen werden.';
|
||||
pdfHandle = null;
|
||||
}
|
||||
};
|
||||
|
||||
const togglePage = (pageNumber: number) => {
|
||||
const next = new Set(selectedPages);
|
||||
if (next.has(pageNumber)) {
|
||||
next.delete(pageNumber);
|
||||
} else {
|
||||
next.add(pageNumber);
|
||||
}
|
||||
selectedPages = next;
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (!pdfHandle) return;
|
||||
const all = new Set<number>();
|
||||
for (let i = 1; i <= pdfHandle.pageCount; i++) all.add(i);
|
||||
selectedPages = all;
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
selectedPages = new Set();
|
||||
};
|
||||
|
||||
const removePages = async () => {
|
||||
if (!pdfFile || !pdfHandle || selectedPages.size === 0) return;
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
||||
const sourcePdf = await PDFDocument.load(bytes);
|
||||
const outputPdf = await PDFDocument.create();
|
||||
|
||||
const pagesToKeep: number[] = [];
|
||||
for (let i = 0; i < sourcePdf.getPageCount(); i++) {
|
||||
if (!selectedPages.has(i + 1)) {
|
||||
pagesToKeep.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (pagesToKeep.length === 0) {
|
||||
error = 'Es muss mindestens eine Seite übrig bleiben.';
|
||||
isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const copiedPages = await outputPdf.copyPages(sourcePdf, pagesToKeep);
|
||||
copiedPages.forEach((page) => outputPdf.addPage(page));
|
||||
|
||||
const resultBytes = await outputPdf.save();
|
||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||
downloadBlob(resultBytes, `${baseName}_ohne_seiten.pdf`);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove pages:', err);
|
||||
error = 'Fehler beim Entfernen der Seiten.';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
pdfHandle?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Seiten entfernen</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">
|
||||
Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !pdfHandle}
|
||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||
{#if loadingError}
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{loadingError}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFileSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={pdfFile?.name ?? ''}
|
||||
class="py-4"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold text-primary-900">
|
||||
{pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
||||
</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectAll}>
|
||||
Alle auswählen
|
||||
</Button>
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectNone}>
|
||||
Auswahl aufheben
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-primary-700">
|
||||
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
|
||||
</p>
|
||||
<Button
|
||||
onclick={removePages}
|
||||
disabled={isProcessing ||
|
||||
selectedPages.size === 0 ||
|
||||
selectedPages.size >= pdfHandle.pageCount}
|
||||
>
|
||||
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,329 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { degrees, PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||
|
||||
type WatermarkKind = 'text' | 'image';
|
||||
type WatermarkPosition =
|
||||
| 'center'
|
||||
| 'diagonal'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
let pdfFile: File | null = $state(null);
|
||||
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||
let watermarkKind = $state<WatermarkKind>('text');
|
||||
let watermarkText = $state('VERTRAULICH');
|
||||
let watermarkImage: File | null = $state(null);
|
||||
let fontSize = $state(50);
|
||||
let imageWidth = $state(160);
|
||||
let opacity = $state(0.3);
|
||||
let rotation = $state(0);
|
||||
let position = $state<WatermarkPosition>('diagonal');
|
||||
let watermarkColor = $state('#cc1a1a');
|
||||
let applyToAll = $state(true);
|
||||
let selectedPages = $state(new Set<number>());
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const watermarkReady = $derived(
|
||||
watermarkKind === 'text' ? watermarkText.trim().length > 0 : watermarkImage !== null
|
||||
);
|
||||
|
||||
const handleFileSelected = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
pdfHandle?.destroy();
|
||||
pdfHandle = null;
|
||||
pdfFile = file;
|
||||
selectedPages = new Set();
|
||||
error = null;
|
||||
|
||||
try {
|
||||
pdfHandle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load PDF for watermarking:', loadError);
|
||||
error = 'PDF konnte nicht geladen werden.';
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelected = (files: File[]) => {
|
||||
watermarkImage = files[0] ?? null;
|
||||
error = null;
|
||||
};
|
||||
|
||||
const togglePage = (pageNumber: number) => {
|
||||
const next = new Set(selectedPages);
|
||||
if (next.has(pageNumber)) next.delete(pageNumber);
|
||||
else next.add(pageNumber);
|
||||
selectedPages = next;
|
||||
};
|
||||
|
||||
const getPositionCoords = (
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
markWidth: number,
|
||||
markHeight: number,
|
||||
markPosition: WatermarkPosition
|
||||
) => {
|
||||
const margin = 20;
|
||||
switch (markPosition) {
|
||||
case 'center':
|
||||
case 'diagonal':
|
||||
return { x: (pageWidth - markWidth) / 2, y: (pageHeight - markHeight) / 2 };
|
||||
case 'top-left':
|
||||
return { x: margin, y: pageHeight - markHeight - margin };
|
||||
case 'top-right':
|
||||
return { x: pageWidth - markWidth - margin, y: pageHeight - markHeight - margin };
|
||||
case 'bottom-left':
|
||||
return { x: margin, y: margin };
|
||||
case 'bottom-right':
|
||||
return { x: pageWidth - markWidth - margin, y: margin };
|
||||
}
|
||||
};
|
||||
|
||||
const parseHexColor = (hexColor: string) => {
|
||||
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hexColor);
|
||||
if (!match) return rgb(0.8, 0.1, 0.1);
|
||||
return rgb(
|
||||
Number.parseInt(match[1], 16) / 255,
|
||||
Number.parseInt(match[2], 16) / 255,
|
||||
Number.parseInt(match[3], 16) / 255
|
||||
);
|
||||
};
|
||||
|
||||
const applyWatermark = async () => {
|
||||
if (!pdfFile || !watermarkReady) return;
|
||||
if (!applyToAll && selectedPages.size === 0) {
|
||||
error = 'Bitte wählen Sie mindestens eine Seite aus.';
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
|
||||
const pages = pdfDoc.getPages();
|
||||
const pageIndexes = applyToAll
|
||||
? pages.map((_, index) => index)
|
||||
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
||||
const numericOpacity = Number(opacity);
|
||||
const numericRotation = position === 'diagonal' ? 45 : Number(rotation);
|
||||
|
||||
if (watermarkKind === 'text') {
|
||||
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const numericFontSize = Number(fontSize);
|
||||
|
||||
for (const pageIndex of pageIndexes) {
|
||||
const page = pages[pageIndex];
|
||||
if (!page) continue;
|
||||
const { width, height } = page.getSize();
|
||||
const textWidth = font.widthOfTextAtSize(watermarkText, numericFontSize);
|
||||
const textHeight = font.heightAtSize(numericFontSize);
|
||||
const coordinates = getPositionCoords(width, height, textWidth, textHeight, position);
|
||||
|
||||
page.drawText(watermarkText, {
|
||||
...coordinates,
|
||||
size: numericFontSize,
|
||||
font,
|
||||
color: parseHexColor(watermarkColor),
|
||||
opacity: numericOpacity,
|
||||
rotate: degrees(numericRotation)
|
||||
});
|
||||
}
|
||||
} else if (watermarkImage) {
|
||||
const imageBytes = new Uint8Array(await watermarkImage.arrayBuffer());
|
||||
const embeddedImage =
|
||||
watermarkImage.type === 'image/jpeg' || /\.jpe?g$/i.test(watermarkImage.name)
|
||||
? await pdfDoc.embedJpg(imageBytes)
|
||||
: await pdfDoc.embedPng(imageBytes);
|
||||
const markWidth = Number(imageWidth);
|
||||
const markHeight = markWidth * (embeddedImage.height / embeddedImage.width);
|
||||
|
||||
for (const pageIndex of pageIndexes) {
|
||||
const page = pages[pageIndex];
|
||||
if (!page) continue;
|
||||
const { width, height } = page.getSize();
|
||||
const coordinates = getPositionCoords(width, height, markWidth, markHeight, position);
|
||||
page.drawImage(embeddedImage, {
|
||||
...coordinates,
|
||||
width: markWidth,
|
||||
height: markHeight,
|
||||
opacity: numericOpacity,
|
||||
rotate: degrees(numericRotation)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||
downloadBlob(await pdfDoc.save(), `${baseName}_wasserzeichen.pdf`);
|
||||
} catch (watermarkError) {
|
||||
console.error('Watermark failed:', watermarkError);
|
||||
error = 'Fehler beim Anwenden des Wasserzeichens.';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => pdfHandle?.destroy());
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Wasserzeichen</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">
|
||||
Fügen Sie allen oder ausgewählten Seiten ein Text- oder Bildwasserzeichen hinzu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !pdfHandle}
|
||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFileSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={pdfFile?.name ?? ''}
|
||||
class="py-4"
|
||||
/>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-primary-200 bg-white p-4">
|
||||
<h2 class="text-lg font-semibold text-primary-900">Wasserzeichen-Einstellungen</h2>
|
||||
|
||||
<fieldset class="flex gap-4">
|
||||
<legend class="mb-2 text-sm font-medium text-primary-900">Art</legend>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="radio" bind:group={watermarkKind} value="text" /> Text
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="radio" bind:group={watermarkKind} value="image" /> Bild
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{#if watermarkKind === 'text'}
|
||||
<label class="block text-sm font-medium text-primary-900" for="watermark-text">
|
||||
Text
|
||||
</label>
|
||||
<input
|
||||
id="watermark-text"
|
||||
type="text"
|
||||
bind:value={watermarkText}
|
||||
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm"
|
||||
/>
|
||||
{:else}
|
||||
<FileDropzone
|
||||
onFilesSelected={handleImageSelected}
|
||||
accept=".png,.jpg,.jpeg,image/png,image/jpeg"
|
||||
ariaLabel="Wasserzeichenbild hinzufügen"
|
||||
label="Wasserzeichenbild ablegen"
|
||||
sublabel={watermarkImage?.name ?? 'PNG oder JPG'}
|
||||
class="py-4"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{#if watermarkKind === 'text'}
|
||||
<label class="text-sm font-medium text-primary-900" for="watermark-font-size">
|
||||
Schriftgröße: {fontSize}
|
||||
</label>
|
||||
<input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} />
|
||||
{:else}
|
||||
<label class="text-sm font-medium text-primary-900" for="watermark-image-width">
|
||||
Bildbreite: {imageWidth}
|
||||
</label>
|
||||
<input
|
||||
id="watermark-image-width"
|
||||
type="range"
|
||||
min="10"
|
||||
max="400"
|
||||
bind:value={imageWidth}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<label class="text-sm font-medium text-primary-900" for="watermark-opacity">
|
||||
Deckkraft: {Math.round(Number(opacity) * 100)}%
|
||||
</label>
|
||||
<input
|
||||
id="watermark-opacity"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.05"
|
||||
bind:value={opacity}
|
||||
/>
|
||||
|
||||
<label class="text-sm font-medium text-primary-900" for="watermark-rotation">
|
||||
Drehung: {rotation}°
|
||||
</label>
|
||||
<input
|
||||
id="watermark-rotation"
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
bind:value={rotation}
|
||||
disabled={position === 'diagonal'}
|
||||
/>
|
||||
|
||||
<label class="text-sm font-medium text-primary-900" for="watermark-position">
|
||||
Position
|
||||
</label>
|
||||
<select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2">
|
||||
<option value="center">Zentriert</option>
|
||||
<option value="diagonal">Diagonal</option>
|
||||
<option value="top-left">Oben links</option>
|
||||
<option value="top-right">Oben rechts</option>
|
||||
<option value="bottom-left">Unten links</option>
|
||||
<option value="bottom-right">Unten rechts</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if watermarkKind === 'text'}
|
||||
<label class="block text-sm font-medium text-primary-900" for="watermark-color">
|
||||
Farbe
|
||||
</label>
|
||||
<input
|
||||
id="watermark-color"
|
||||
type="color"
|
||||
bind:value={watermarkColor}
|
||||
class="h-10 w-14 cursor-pointer"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-primary-900" for="all-pages">
|
||||
<input type="checkbox" id="all-pages" bind:checked={applyToAll} />
|
||||
Auf alle Seiten anwenden
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !applyToAll}
|
||||
<div>
|
||||
<p class="mb-3 text-sm font-medium">Seiten auswählen:</p>
|
||||
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
onclick={applyWatermark}
|
||||
disabled={isProcessing || !watermarkReady || (!applyToAll && selectedPages.size === 0)}
|
||||
>
|
||||
{isProcessing ? 'Wird verarbeitet...' : 'Wasserzeichen anwenden & herunterladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user