metadata
Build and Push Docker Image / build (push) Successful in 45s

This commit is contained in:
2026-08-29 12:31:06 +02:00
parent d71b25f834
commit b7969b4c9e
7 changed files with 426 additions and 10 deletions
+8 -8
View File
@@ -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
};
+108
View File
@@ -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();
};
+6
View File
@@ -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'
}
];
+3 -1
View File
@@ -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) =>
+156
View 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>