From b7969b4c9ea0a81999fe1cdea9d0909702fd56b3 Mon Sep 17 00:00:00 2001 From: rehlert Date: Sat, 29 Aug 2026 12:31:06 +0200 Subject: [PATCH] metadata --- agents.md | 8 +- src/lib/components/ui/card/index.ts | 16 +-- src/lib/pdf-metadata.ts | 108 +++++++++++++++++ src/lib/tools.ts | 6 + src/routes/+page.svelte | 4 +- src/routes/tools/metadata/+page.svelte | 156 +++++++++++++++++++++++++ tests/e2e/metadata.spec.ts | 138 ++++++++++++++++++++++ 7 files changed, 426 insertions(+), 10 deletions(-) create mode 100644 src/lib/pdf-metadata.ts create mode 100644 src/routes/tools/metadata/+page.svelte create mode 100644 tests/e2e/metadata.spec.ts diff --git a/agents.md b/agents.md index 2885bfa..3ca7174 100644 --- a/agents.md +++ b/agents.md @@ -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 diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts index 4d3fce4..77d3674 100644 --- a/src/lib/components/ui/card/index.ts +++ b/src/lib/components/ui/card/index.ts @@ -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 }; diff --git a/src/lib/pdf-metadata.ts b/src/lib/pdf-metadata.ts new file mode 100644 index 0000000..fadd00f --- /dev/null +++ b/src/lib/pdf-metadata.ts @@ -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 = (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 => { + 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 => { + 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(); +}; diff --git a/src/lib/tools.ts b/src/lib/tools.ts index 65d587e..ac3fd9d 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -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' } ]; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 843a2fa..a12e8de 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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) => diff --git a/src/routes/tools/metadata/+page.svelte b/src/routes/tools/metadata/+page.svelte new file mode 100644 index 0000000..aa86ec0 --- /dev/null +++ b/src/routes/tools/metadata/+page.svelte @@ -0,0 +1,156 @@ + + +
+
+

Metadaten

+

+ Prüfen Sie, welche Informationen Ihr PDF enthält, und entfernen Sie sie vor dem Weitergeben. +

+
+ + + + {#if isReading} +

Metadaten werden gelesen …

+ {:else if error} + + {:else if result} + {#if result.status === 'encrypted'} + + {:else} +
+
+ +

Dokument-Informationen

+
+

{file?.name}

+ +
+ {#each infoRows as row (row.label)} +
{row.label}
+
+ {row.value || '–'} +
+ {/each} +
+ + {#if result.metadata.embeddedFileSuspected} +
+ +

+ Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen + nicht entfernt. +

+
+ {/if} + +
+

+ 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. +

+
+
+ {/if} + +
+ +
+ {/if} +
diff --git a/tests/e2e/metadata.spec.ts b/tests/e2e/metadata.spec.ts new file mode 100644 index 0000000..b29e102 --- /dev/null +++ b/tests/e2e/metadata.spec.ts @@ -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([]); +});