@@ -84,6 +84,7 @@ src/
|
||||
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
||||
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
||||
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
|
||||
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
|
||||
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
|
||||
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
||||
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
|
||||
@@ -96,7 +97,7 @@ src/
|
||||
├── tools/
|
||||
│ ├── +layout.svelte # Shared back-navigation shell
|
||||
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
||||
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate}/
|
||||
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
|
||||
├── datenschutz/+page.svelte # Privacy policy
|
||||
└── impressum/+page.svelte # Imprint
|
||||
static/ # Public logo, footer art, and robots.txt
|
||||
@@ -112,6 +113,11 @@ committed.
|
||||
|
||||
- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
|
||||
`/tools/bea`; the file is not uploaded or persisted.
|
||||
- `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document
|
||||
properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }`
|
||||
result for password-protected files, and detects embedded-file markers by scanning raw bytes.
|
||||
Cleaning neutralizes the standard Info dictionary fields and re-saves through pdf-lib; embedded
|
||||
files and XMP streams are out of scope and are disclosed in the UI instead.
|
||||
- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
|
||||
thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
|
||||
- The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
import Root from './card.svelte';
|
||||
import Content from './card-content.svelte';
|
||||
import Description from './card-description.svelte';
|
||||
import Footer from './card-footer.svelte';
|
||||
import Header from './card-header.svelte';
|
||||
import Title from './card-title.svelte';
|
||||
import Action from './card-action.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
@@ -21,5 +21,5 @@ export {
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
Action as CardAction
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export type PdfMetadataInfo = {
|
||||
title: string;
|
||||
author: string;
|
||||
subject: string;
|
||||
keywords: string[];
|
||||
creator: string;
|
||||
producer: string;
|
||||
creationDate: Date | null;
|
||||
modificationDate: Date | null;
|
||||
pageCount: number;
|
||||
embeddedFileSuspected: boolean;
|
||||
};
|
||||
|
||||
export type PdfMetadataResult =
|
||||
| { status: 'ok'; metadata: PdfMetadataInfo }
|
||||
| { status: 'encrypted' };
|
||||
|
||||
// The pdf-lib error class for encrypted documents is not exported from the
|
||||
// package root, so we identify it by its message instead.
|
||||
const isEncryptedPdfError = (error: unknown) =>
|
||||
error instanceof Error && error.message.includes('is encrypted');
|
||||
|
||||
const safeRead = <T>(read: () => T | undefined): T | undefined => {
|
||||
try {
|
||||
return read();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const toDateOrNull = (value: Date | undefined): Date | null =>
|
||||
value && !Number.isNaN(value.getTime()) ? value : null;
|
||||
|
||||
// Finds an ASCII marker in raw bytes without decoding the whole file. The
|
||||
// search for '/EmbeddedFile' also matches '/EmbeddedFiles' as a prefix.
|
||||
const containsAsciiMarker = (bytes: Uint8Array, marker: string): boolean => {
|
||||
const firstByte = marker.charCodeAt(0);
|
||||
for (let index = 0; index <= bytes.length - marker.length; index += 1) {
|
||||
if (bytes[index] !== firstByte) continue;
|
||||
let matched = true;
|
||||
for (let offset = 1; offset < marker.length; offset += 1) {
|
||||
if (bytes[index + offset] !== marker.charCodeAt(offset)) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasEmbeddedFileMarkers = (bytes: Uint8Array): boolean =>
|
||||
containsAsciiMarker(bytes, '/EmbeddedFile');
|
||||
|
||||
export const readPdfMetadata = async (bytes: Uint8Array): Promise<PdfMetadataResult> => {
|
||||
const embeddedFileSuspected = hasEmbeddedFileMarkers(bytes);
|
||||
|
||||
let document: PDFDocument;
|
||||
try {
|
||||
document = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
} catch (error) {
|
||||
if (isEncryptedPdfError(error)) return { status: 'encrypted' };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const keywords = safeRead(() => document.getKeywords());
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
metadata: {
|
||||
title: safeRead(() => document.getTitle()) ?? '',
|
||||
author: safeRead(() => document.getAuthor()) ?? '',
|
||||
subject: safeRead(() => document.getSubject()) ?? '',
|
||||
// PDF keyword lists have no canonical separator; split only on
|
||||
// explicit punctuation so multi-word keywords survive.
|
||||
keywords: keywords
|
||||
? keywords
|
||||
.split(/[;,]/)
|
||||
.map((keyword) => keyword.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
creator: safeRead(() => document.getCreator()) ?? '',
|
||||
producer: safeRead(() => document.getProducer()) ?? '',
|
||||
creationDate: toDateOrNull(safeRead(() => document.getCreationDate())),
|
||||
modificationDate: toDateOrNull(safeRead(() => document.getModificationDate())),
|
||||
pageCount: document.getPageCount(),
|
||||
embeddedFileSuspected
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const stripPdfMetadata = async (bytes: Uint8Array): Promise<Uint8Array> => {
|
||||
const document = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
const now = new Date();
|
||||
|
||||
document.setTitle('');
|
||||
document.setAuthor('');
|
||||
document.setSubject('');
|
||||
document.setKeywords([]);
|
||||
document.setCreator('');
|
||||
document.setProducer('beA-Edit');
|
||||
document.setCreationDate(now);
|
||||
document.setModificationDate(now);
|
||||
|
||||
return document.save();
|
||||
};
|
||||
@@ -59,5 +59,11 @@ export const tools: Tool[] = [
|
||||
title: 'PDFs zusammenfügen',
|
||||
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
|
||||
icon: 'merge'
|
||||
},
|
||||
{
|
||||
slug: 'metadata',
|
||||
title: 'Metadaten',
|
||||
description: 'Zeigen Sie Metadaten eines PDFs an und entfernen Sie sie.',
|
||||
icon: 'info'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
Merge,
|
||||
Stamp,
|
||||
RotateCw,
|
||||
Info,
|
||||
FileText
|
||||
} from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -27,7 +28,8 @@
|
||||
scissors: Scissors,
|
||||
merge: Merge,
|
||||
stamp: Stamp,
|
||||
'rotate-cw': RotateCw
|
||||
'rotate-cw': RotateCw,
|
||||
info: Info
|
||||
};
|
||||
|
||||
const isZipFile = (file: File) =>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { formatBytes } from '$lib/batch';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { readPdfMetadata, stripPdfMetadata, type PdfMetadataResult } from '$lib/pdf-metadata';
|
||||
import { AlertTriangle, Info, Lock } from '@lucide/svelte';
|
||||
|
||||
let file = $state<File | null>(null);
|
||||
let result = $state<PdfMetadataResult | null>(null);
|
||||
let isReading = $state(false);
|
||||
let isCleaning = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const cleanedFileName = $derived(file ? `${file.name.replace(/\.pdf$/i, '')}_bereinigt.pdf` : '');
|
||||
|
||||
const handleFilesSelected = async (files: File[]) => {
|
||||
const selected = files[0];
|
||||
if (!selected) return;
|
||||
|
||||
file = selected;
|
||||
result = null;
|
||||
error = null;
|
||||
isReading = true;
|
||||
try {
|
||||
const bytes = new Uint8Array(await selected.arrayBuffer());
|
||||
result = await readPdfMetadata(bytes);
|
||||
} catch {
|
||||
file = null;
|
||||
error = 'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.';
|
||||
} finally {
|
||||
isReading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCleaned = async () => {
|
||||
if (!file || isCleaning) return;
|
||||
isCleaning = true;
|
||||
error = null;
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const cleaned = await stripPdfMetadata(bytes);
|
||||
downloadBlob(cleaned, cleanedFileName);
|
||||
} catch {
|
||||
error = 'Die Metadaten konnten nicht entfernt werden. Bitte versuchen Sie es erneut.';
|
||||
} finally {
|
||||
isCleaning = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value: Date | null) =>
|
||||
value ? value.toLocaleString('de-DE', { dateStyle: 'long', timeStyle: 'short' }) : '';
|
||||
|
||||
const infoRows = $derived.by(() => {
|
||||
if (!result || result.status !== 'ok') return [];
|
||||
const metadata = result.metadata;
|
||||
return [
|
||||
{ label: 'Titel', value: metadata.title },
|
||||
{ label: 'Autor', value: metadata.author },
|
||||
{ label: 'Betreff', value: metadata.subject },
|
||||
{ label: 'Schlüsselwörter', value: metadata.keywords.join(', ') },
|
||||
{ label: 'Erstellt mit', value: metadata.creator },
|
||||
{ label: 'PDF-Erzeuger', value: metadata.producer },
|
||||
{ label: 'Erstellt am', value: formatDate(metadata.creationDate) },
|
||||
{ label: 'Geändert am', value: formatDate(metadata.modificationDate) },
|
||||
{ label: 'Seiten', value: String(metadata.pageCount) },
|
||||
{ label: 'Größe', value: file ? formatBytes(file.size) : '' },
|
||||
{ label: 'Verschlüsselung', value: 'Nein' }
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Metadaten</h1>
|
||||
<p class="mt-2 text-sm text-primary">
|
||||
Prüfen Sie, welche Informationen Ihr PDF enthält, und entfernen Sie sie vor dem Weitergeben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFilesSelected}
|
||||
label={file ? 'Anderes PDF auswählen' : 'PDF auswählen'}
|
||||
sublabel="PDF hier ablegen oder zum Auswählen klicken"
|
||||
ariaLabel="PDF-Dateien hinzufügen"
|
||||
/>
|
||||
|
||||
{#if isReading}
|
||||
<p class="text-sm text-primary" role="status">Metadaten werden gelesen …</p>
|
||||
{:else if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
{:else if result}
|
||||
{#if result.status === 'encrypted'}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 flex gap-3" role="alert">
|
||||
<Lock class="h-5 w-5 shrink-0 text-red-600 mt-0.5" />
|
||||
<div class="text-sm text-red-800">
|
||||
<p class="font-medium">Dieses PDF ist verschlüsselt</p>
|
||||
<p class="mt-1">
|
||||
Verschlüsselte Dokumente können nicht gelesen werden. Bitte entfernen Sie zuerst das
|
||||
Passwort mit dem Werkzeug
|
||||
<a href="/tools/decrypt" class="font-medium underline">Passwort entfernen</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-border bg-card p-6 space-y-5" data-metadata-card>
|
||||
<div class="flex items-center gap-2 text-foreground">
|
||||
<Info class="h-5 w-5" />
|
||||
<h2 class="text-lg font-semibold">Dokument-Informationen</h2>
|
||||
</div>
|
||||
<p class="text-xs text-primary truncate" title={file?.name}>{file?.name}</p>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-[12rem_1fr]">
|
||||
{#each infoRows as row (row.label)}
|
||||
<dt class="text-sm font-medium text-foreground">{row.label}</dt>
|
||||
<dd class="text-sm text-primary wrap-break-word" data-metadata-value={row.label}>
|
||||
{row.value || '–'}
|
||||
</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
|
||||
{#if result.metadata.embeddedFileSuspected}
|
||||
<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" />
|
||||
<p class="text-sm text-amber-800">
|
||||
Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen
|
||||
nicht entfernt.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/50 p-4 text-sm text-primary">
|
||||
<p>
|
||||
Beim Bereinigen bleibt der Inhalt vollständig erhalten (kein Qualitätsverlust). Entfernt
|
||||
werden die Standard-Dokumenteigenschaften (Titel, Autor, Betreff usw.). Nicht entfernt
|
||||
werden sogenannte XMP-Metadaten (Extensible Metadata Platform): Diese stehen nicht im
|
||||
Bereich „Dokumenteigenschaften“, sondern können direkt ins PDF-Dokument oder in
|
||||
eingebettete Bilder eingebettet sein.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
onclick={downloadCleaned}
|
||||
disabled={result.status !== 'ok' || isCleaning}
|
||||
data-clean-download
|
||||
>
|
||||
{isCleaning ? 'Wird bereinigt …' : 'Bereinigt herunterladen'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,138 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
import {
|
||||
createEncryptedPdf,
|
||||
createPdf,
|
||||
dropFiles,
|
||||
expectPdfDownload,
|
||||
pdfDropzone,
|
||||
trackPageErrors
|
||||
} from './helpers';
|
||||
|
||||
const createPdfWithMetadata = async () => {
|
||||
const document = await PDFDocument.create();
|
||||
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||
for (let pageNumber = 1; pageNumber <= 2; pageNumber += 1) {
|
||||
const page = document.addPage([420, 594]);
|
||||
page.drawText(`Vertraulich – Seite ${pageNumber}`, {
|
||||
x: 40,
|
||||
y: 520,
|
||||
size: 20,
|
||||
font,
|
||||
color: rgb(0.1, 0.2, 0.4)
|
||||
});
|
||||
}
|
||||
document.setTitle('Schriftsatz 12. Kammer');
|
||||
document.setAuthor('Dr. Echte Anwältin');
|
||||
document.setSubject('Klageerwiderung');
|
||||
document.setKeywords(['vertraulich, Mandat 4711']);
|
||||
document.setCreator('Anwaltssoftware Pro');
|
||||
document.setProducer('PDF-Fabrik 3.2');
|
||||
document.setCreationDate(new Date('2024-05-06T09:12:00Z'));
|
||||
document.setModificationDate(new Date('2024-05-07T16:40:00Z'));
|
||||
return Buffer.from(await document.save());
|
||||
};
|
||||
|
||||
const metadataValue = (page: Page, label: string) =>
|
||||
page.locator(`[data-metadata-value="${label}"]`);
|
||||
|
||||
test('metadata tool shows document properties of a dropped PDF', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page);
|
||||
await page.goto('/tools/metadata');
|
||||
await dropFiles(page, pdfDropzone(page), [
|
||||
{
|
||||
name: 'schriftsatz.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: await createPdfWithMetadata()
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(metadataValue(page, 'Titel')).toHaveText('Schriftsatz 12. Kammer');
|
||||
await expect(metadataValue(page, 'Autor')).toHaveText('Dr. Echte Anwältin');
|
||||
await expect(metadataValue(page, 'Betreff')).toHaveText('Klageerwiderung');
|
||||
await expect(metadataValue(page, 'Schlüsselwörter')).toHaveText('vertraulich, Mandat 4711');
|
||||
await expect(metadataValue(page, 'Erstellt mit')).toHaveText('Anwaltssoftware Pro');
|
||||
await expect(metadataValue(page, 'PDF-Erzeuger')).toHaveText('PDF-Fabrik 3.2');
|
||||
await expect(metadataValue(page, 'Erstellt am')).toContainText('2024');
|
||||
await expect(metadataValue(page, 'Geändert am')).toContainText('2024');
|
||||
await expect(metadataValue(page, 'Seiten')).toHaveText('2');
|
||||
await expect(metadataValue(page, 'Verschlüsselung')).toHaveText('Nein');
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('metadata tool shows a dash for empty fields', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page);
|
||||
await page.goto('/tools/metadata');
|
||||
await dropFiles(page, pdfDropzone(page), [
|
||||
{ name: 'nackt.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Nackt') }
|
||||
]);
|
||||
|
||||
await expect(metadataValue(page, 'Titel')).toHaveText('–');
|
||||
await expect(metadataValue(page, 'Autor')).toHaveText('–');
|
||||
await expect(metadataValue(page, 'Schlüsselwörter')).toHaveText('–');
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('metadata tool downloads a cleaned PDF with empty document properties', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page);
|
||||
await page.goto('/tools/metadata');
|
||||
await dropFiles(page, pdfDropzone(page), [
|
||||
{
|
||||
name: 'schriftsatz.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: await createPdfWithMetadata()
|
||||
}
|
||||
]);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Bereinigt herunterladen' }).click();
|
||||
const bytes = await expectPdfDownload(await downloadPromise, /^schriftsatz_bereinigt\.pdf$/);
|
||||
|
||||
const document = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
expect((document.getTitle() ?? '').trim()).toBe('');
|
||||
expect((document.getAuthor() ?? '').trim()).toBe('');
|
||||
expect((document.getSubject() ?? '').trim()).toBe('');
|
||||
expect((document.getKeywords() ?? '').trim()).toBe('');
|
||||
expect(document.getProducer()).toBe('beA-Edit');
|
||||
expect(document.getPageCount()).toBe(2);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('metadata tool rejects encrypted PDFs with a decrypt hint', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page);
|
||||
await page.goto('/tools/metadata');
|
||||
await dropFiles(page, pdfDropzone(page), [
|
||||
{
|
||||
name: 'geheim.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: await createEncryptedPdf(1, 'Geheim', 'test-passwort')
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(page.getByText('Dieses PDF ist verschlüsselt')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Passwort entfernen' })).toHaveAttribute(
|
||||
'href',
|
||||
'/tools/decrypt'
|
||||
);
|
||||
await expect(page.getByRole('button', { name: 'Bereinigt herunterladen' })).toBeDisabled();
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('metadata tool warns when embedded files are suspected', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page);
|
||||
const pdf = await createPdf(1, 'Anlage');
|
||||
const withMarker = Buffer.concat([pdf, Buffer.from('\n% /EmbeddedFile\n')]);
|
||||
|
||||
await page.goto('/tools/metadata');
|
||||
await dropFiles(page, pdfDropzone(page), [
|
||||
{ name: 'mit-anlage.pdf', mimeType: 'application/pdf', buffer: withMarker }
|
||||
]);
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen nicht entfernt.'
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Bereinigt herunterladen' })).toBeEnabled();
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user