Author SHA1 Message Date
rehlert 3b04039aa9 refactoring, numbering plan 2026-10-28 16:09:53 +01:00
rehlert b5279434b2 implements page-numbers-plan 2026-10-28 09:25:15 +01:00
rehlert 39da8dac4d updates
Build and Push Docker Image / build (push) Successful in 1m39s
2026-10-24 16:43:50 +02:00
52 changed files with 5598 additions and 1545 deletions

No files matched your search

+86 -16
View File
@@ -38,7 +38,7 @@ pnpm build # Create the adapter-node production build
pnpm preview # Preview the production build
pnpm check # Run Svelte and TypeScript diagnostics
pnpm check:watch # Run diagnostics in watch mode
pnpm test:e2e # Run the Playwright toolbox tests in Chromium
pnpm test:e2e # Run the toolbox specs (tests/e2e) and the unit specs (tests/unit)
pnpm test:pwa # PWA offline/update tests against the production build (requires pnpm build)
pnpm lint # Check repository formatting with Prettier
pnpm format # Format the repository with Prettier
@@ -74,9 +74,13 @@ src/
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
│ │ ├── BeaArchiveProcessing.svelte
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
│ │ ├── ExhibitFileList.svelte # Reorderable Anlagen list: labels, renames, results
│ │ ├── ProcessedZipArchiveEditor.svelte
│ │ ├── PaginationPreview.svelte # Rendered page preview incl. shrink hints and page nav
│ │ ├── PdfSplitDialog.svelte # Page-level PDF splitting into Teildokumente
│ │ ├── SinglePdfTool.svelte # Shell for one-PDF tools (header, dropzone, slots)
│ │ ├── SubDocumentEditor.svelte
│ │ ├── ToolError.svelte # Shared error banner (role="alert")
│ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
@@ -89,11 +93,19 @@ src/
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
│ │ └── zip-inflating.service.ts # Async fflate wrapper
│ ├── batch.ts # Sequential batch engine for multi-file tools
│ ├── download.ts # Blob/download helpers (pdfBytesToBlob, downloadBlobFile)
│ ├── exhibit-naming.ts # Anlagen label, start number and file-name helpers
│ ├── latest-task.ts # "Only the latest async call wins" tokens
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
│ ├── pdf-errors.ts # Shared PDF error classification and German messages
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
│ ├── pdf-overlay.ts # Shared overlay positions, preview CSS and color parsing
│ ├── pdf-pagination.ts # Sheet-number labels and the pagination pipeline
│ ├── pdf-preview-text.ts # Canvas text measurement for HTML previews
│ ├── pdf-processing.ts # Reusable PDF merge helper
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
│ ├── pdf-session.svelte.ts # Shared single-PDF lifecycle (file, bytes, handle, errors)
│ ├── pdf-text-overlay.ts # Shared framed/rotated text and image overlay drawing
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnails, on-demand page previews, cleanup
│ ├── utils.ts # cn() and shared component utility types
│ └── zip-processing.ts # Archive model, transforms, and PDF export
└── routes/
@@ -104,13 +116,15 @@ src/
├── tools/
│ ├── +layout.svelte # Shared back-navigation shell
│ ├── bea/+page.svelte # Existing ZIP archive workflow
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
│ └── {exhibits,pagination,merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint
static/ # Public logo, footer art, PWA icons, and robots.txt
components.json # shadcn-svelte registry configuration
Dockerfile # Node adapter production image
playwright.pwa.config.ts # Playwright config for `pnpm test:pwa`
tests/e2e/ # Playwright toolbox specs
tests/unit/ # Pure-function specs (run by the same Playwright config)
plan/ # Design and implementation notes
```
@@ -146,7 +160,20 @@ committed.
## Application flow and state
- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and
forwarded to `/tools/bea`; the file is not uploaded or persisted.
forwarded to `/tools/bea`; the file is not uploaded or persisted. The same `queuePendingFiles` /
`takePendingFiles` handoff carries the merged PDF from `/tools/merge` to `/tools/pagination`.
- `/tools/pagination` is a single-PDF tool: it renders one page at a time through
`loadPdfPageRenderer` (never the all-pages thumbnail array), composes every label with
`buildPaginationLabel` — the single source for both the live preview and the export — and stamps
all pages through `drawTextOverlay`. The page number is always on every page; the optional free
text (Aktenzeichen) follows its own “every page” / “first page only” scope.
- `/tools/exhibits` is the Anlagen tool and the only batch tool with an order. One dropped PDF is
one Anlage: `src/lib/exhibit-naming.ts` owns the label (`buildExhibitLabel`, no padded numbers in
the stamp) and the optional file name (`buildExhibitFileName`, `{prefixSlug}_{paddedNumber}_…`,
padding from `getExhibitNumberWidth`). Numbers are frozen per row when the run starts
(`assignments` map), so reordering or changing the start number never rewrites a created Anlage —
the list keeps showing the frozen label while pending rows follow the live order. The stamp is
`drawTextOverlay` with `framed: true`, `compensatePageRotation: true`, first page by default.
- `/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.
@@ -231,6 +258,35 @@ All case-file processing is intentionally local to the browser.
- Page-set copies (separate/extract tools, PDF splitting) share
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
semantics cannot drift apart.
- `src/lib/pdf-text-overlay.ts` owns text fitting, the rotated-box placement and the optional
`/Rotate` compensation. Stamp keeps `compensatePageRotation` off so its output does not change;
pagination turns it on so numbers stay at the visual position on rotated scans. `drawImageOverlay`
is the image counterpart. Share both instead of re-implementing overlay maths in a route, and take
the margin/padding presets (`TEXT_OVERLAY_MARGIN`, `TEXT_OVERLAY_PADDING`,
`MIN_TEXT_OVERLAY_SIZE`) from the module rather than redefining them locally.
- `src/lib/pdf-errors.ts` classifies PDF failures (`classifyPdfError`, `TextOverlayError`,
`isEncryptedPdfError`) and owns the German copy (`pdfLoadErrorMessage`, `pdfProcessingErrorMessage`,
both with a tool-specific fallback). All tools use it; never match on pdf-lib/pdfjs error text in a
route, and never copy the password hint locally.
- `src/lib/pdf-session.svelte.ts` (`createPdfSession`) is the shared single-PDF lifecycle for rotate,
separate and pagination: it reads the bytes once, builds the tool handle, maps load errors and
invalidates stale loads. Keep new single-PDF tools on it instead of re-declaring
`file`/`bytes`/`handle`/`isLoading`/`loadingError` and a generation counter.
- `src/lib/latest-task.ts` (`createLatestToken`) is the guard for "only the newest async call may
publish its result" (file loads, preview renders). Do not add another hand-rolled counter.
- Markup is shared the same way: `ToolError.svelte` owns the red `role="alert"` banner (never
inline the classes in a route), `SinglePdfTool.svelte` owns the header + dropzone + toolbar +
footer layout for one-PDF tools (rotate, separate — keep new ones on it), and
`PaginationPreview.svelte` owns the page preview with its shrink hints. Tool-specific copy is
passed in as props; keep it out of the shared components.
- `src/lib/pending-files.ts` hands files between routes. Queue with an explicit target
(`queuePendingFiles(files, 'bea' | 'pagination')`) and consume with `takePendingFiles(target)` so a
tool cannot silently swallow a file queued for another one.
- `src/lib/pdf-thumbnails.ts` offers two loaders: `loadPdfDocument` (all thumbnails, for pickers and
page grids) and `loadPdfPageRenderer` (one page at a time, for long documents). Both share
`PdfPageImage` and one rendering helper, revoke object URLs and tear down the pdfjs loading task —
including when the initial `getDocument` promise rejects. Superseded renders throw
`PreviewRenderCancelledError`, which callers swallow instead of logging.
- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before
rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
safeguards.
@@ -243,16 +299,23 @@ called in the browser.
## Batch processing pattern for single-PDF tools
Tools that transform one PDF per run (compress, watermark, encrypt, …) are being retrofitted to
accept multiple files. Follow the established compress retrofit when adding the pattern to another
tool; see `plan/batch-processing-plan.md` for the full design.
Tools that transform one PDF per run (compress, watermark, stamp, encrypt, decrypt) accept multiple
files. Follow the established compress retrofit when adding the pattern to another tool; see
`plan/done/batch-processing-plan.md` for the full design.
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PDF in flight for
memory safety), never throws — per-item failures are caught and written to the item as a German
error message — and consults the optional `shouldProcess` predicate so rows removed mid-run are
options?)`, `formatBytes`, `batchStatusLabel`/`batchStatusClass` and
`shouldWarnAboutBatchMemory`. `process` is `(file, item) => Promise<{ bytes, name }>`: the item is
passed so a tool can resolve per-row data frozen at run start (the Anlagen tool resolves its
exhibit number that way; `(file) => …` callbacks stay valid). `runBatch` processes strictly
sequentially (one PDF in flight for memory safety), never throws — per-item failures are caught
and written to the item as a German error message (a string, or a formatter that can inspect the
thrown error) — and consults the optional `shouldProcess` predicate so rows removed mid-run are
skipped.
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip
- `src/lib/batch.ts` also owns the bulk export: `uniqueEntryName` (duplicate suffixes) and
`downloadBatchZip(items, zipBaseName)` → `{toolname}_{date}.zip`. Keep new batch tools on it
instead of building ZIPs inside a component.
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip (`batchStatusClass`/`batchStatusLabel`)
(Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with
accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a
total result-size summary, a memory warning (>20 files or any file >25 MB), and the bulk
@@ -262,10 +325,17 @@ options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PD
deduplicate by name + size), pass a `process(file)` callback that reads the current option state
so option changes mid-run affect only subsequent files, and gate the run button on pending items.
Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
of blocking the batch.
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, encrypt
`geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the batch
flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
of blocking the batch. Keep thumbnails, previews, and page-specific selection to a single dropped
file; multi-file runs apply the tool to every page of every file.
- `src/lib/components/ExhibitFileList.svelte` is the other batch list, for tools where order and a
derived per-row label matter (currently `/tools/exhibits`): drag handle plus up/down buttons that
both call one `onMove(from, to)`, position badge, the resolved label, the renamed file name, and
the same status/download/retry/remove/ZIP surface as `BatchFileList`. Reordering and preview
selection are disabled while a run is active.
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, stamp `stempel`,
exhibits `anlagen`, encrypt `geschuetzt`, decrypt `entsperrt`. Choose an analogous German name
per tool and cover the
batch flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
## Svelte conventions
+17 -17
View File
@@ -16,39 +16,39 @@
"test:pwa": "playwright test -c playwright.pwa.config.ts"
},
"devDependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@lucide/svelte": "^1.18.0",
"@playwright/test": "^1.62.1",
"@fontsource-variable/geist": "^5.3.0",
"@lucide/svelte": "^1.61.0",
"@playwright/test": "^1.64.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.6.5",
"@vite-pwa/sveltekit": "^1.1.0",
"bits-ui": "2.18.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"prettier": "^3.9.10",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn-svelte": "1.3.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"svelte": "^5.57.2",
"svelte-check": "^4.7.7",
"tailwind-merge": "3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vite": "^8.3.5",
"vite-plugin-pwa": "^1.3.0",
"workbox-window": "^7.4.1"
},
"dependencies": {
"@cantoo/pdf-lib": "^2.9.1",
"@cantoo/pdf-lib": "^2.11.1",
"fflate": "^0.8.3",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^6.0.227"
"pdfjs-dist": "^6.4.299"
}
}
+237
View File
@@ -0,0 +1,237 @@
# Plan: Anlagen-Bezeichnung — Anlagen nummerieren und benennen
## Goal
Add a new tool **„Anlagen nummerieren“** (`/tools/exhibits`): users drop any number of PDFs, sort
them, choose an exhibit label („Anlage“, „Anlage K“, „Anlage B“, „Anlage XYZ“ or free text), a start
number and the page scope, and receive each PDF stamped with its exhibit number — optionally renamed
to `Anlage_K_06_Originalname.pdf` for court submission.
Context: mail thread with Nikolai Klute (26.09.2026 / 06.10.2026). The requirement is explicitly
_one PDF = one Anlage = one number_, with sortable order, configurable label and counter start, and
optional filename prefixing. Per-page counters are the separate pagination plan.
Related: `plan/done/page-numbers-plan.md` (shared overlay/renderer), `plan/done/stamp-plan.md`,
`plan/done/batch-processing-plan.md`.
---
## Open decisions (defaults marked ✅)
### D1 — Numbering unit
- ✅ **A. One uploaded PDF = one Anlage = one number.** An „Anlage K 6“ may have many pages; the
stamp identifies the document, not the sheet.
- B. Group several PDFs into one Anlage. Not requested; deferred (a group would need its own reorder
UI inside a group).
### D2 — Stamped pages
- ✅ **A. First page by default,** checkbox „Auf jeder Seite stempeln“ for every page of that
exhibit.
- B. Every page by default. Real exhibit stamps usually mark the first page; the stamp tool already
covers „everywhere“.
### D3 — Look
- ✅ **A. Framed exhibit stamp:** `Anlage K 6` in title case, HelveticaBold 14 pt (slider 10–24),
black `#1a1a1a` (color input included, same control as the stamp tool), 2 pt border, 6 pt padding,
no rotation, no date, default position **oben rechts**, 20 pt margin; the six other positions are
selectable.
- B. Reuse the red authority stamp look. Wrong signal: an exhibit label is a designation, not an
endorsement.
### D4 — Label and presets
- ✅ **A. Preset chips + editable text.** Chips `Anlage`, `Anlage K`, `Anlage B`, `Anlage XYZ`
(`maxlength="40"`, default `Anlage`). The stamp text is `${prefix} ${number}` with a single space —
no leading zeros in the stamp (`Anlage K 6`, not `Anlage K 06`).
- B. Fixed list only. Firms use their own exhibit letters.
### D5 — Filename renaming
- ✅ **A. Optional, default on.** Pattern `{prefixSlug}_{paddedNumber}_{originalBase}.pdf`, e.g.
`Anlage_K_06_Klageschrift.pdf`. Prefix slug: trim, collapse whitespace to `_`, strip
`/\:*?"<>|` and control characters, keep German letters. Padding = `max(2, digits of highest
assigned number)`, so 1…99 → `06`, 1…100 → `006`.
Renaming off keeps the original file name exactly (ZIP duplicate names are still deduplicated by
the batch ZIP helper).
- B. Always rename. Users sometimes want to control file names themselves.
### D6 — Ordering
- ✅ **A. Drag-and-drop with a visible handle, plus up/down buttons.** Buttons are the keyboard and
Playwright path; both call the same `moveItem(from, to)`. Reordering is disabled while a run is
active.
- B. Up/down only. Too slow for 30 exhibits.
### D7 — Number freezing
- ✅ **A. Numbers are frozen when a run starts** (`itemId → start + index`). Results keep the number
they were stamped with even if the user reorders afterwards; the list shows
„Bereits erstellte Anlagen behalten ihre Nummer.“ Retry uses the frozen number. A clean renumber
means removing results and running again.
- B. Live renumbering after processing. Would silently invalidate downloaded files and produce
duplicate numbers.
### D8 — Deliverables
- ✅ **A. Per-row downloads + one ZIP „Alle herunterladen“** via the existing batch ZIP engine
(`anlagen_2026-10-06.zip`). A merged „Anlagenkonvolut“ stays out of scope — „PDFs zusammenfügen“
exists.
- B. Always one merged PDF. Not requested and destroys per-exhibit files.
### D9 — Preview
- ✅ **A. Preview only for the selected row**, page 1 rendered on demand (shared
`loadPdfPageRenderer` from the pagination plan), overlay scaled like the stamp preview; selecting
another row disposes the previous handle.
- B. Thumbnail per row. Renders every dropped PDF completely — memory trap.
---
## UX
Route `/tools/exhibits`, title „Anlagen nummerieren“, registry entry next to `stamp`:
```ts
{
slug: 'exhibits',
title: 'Anlagen nummerieren',
description: 'Nummerieren und benennen Sie Anlagen für die Einreichung — mit Anlagenstempel.',
icon: 'files'
}
```
- **Header/description:** „Versehen Sie mehrere PDFs mit fortlaufenden Anlagen-Bezeichnungen und
benennen Sie die Dateien passend um.“
- **FileDropzone** (multiple) — „PDFs als Anlagen hinzufügen“; appending keeps the current list
(same dedupe by name + size as the stamp tool).
- **Numbered list** (`ExhibitFileList.svelte`, not `BatchFileList` — it needs reordering and number
labels):
- per row: drag handle, order badge, computed label („Anlage K 6“), file name, renamed preview
(`→ Anlage_K_06_Klageschrift.pdf`), status chip, per-row download / retry / remove, and a
„Vorschau“ selection.
- live label updates for pending rows when order, prefix or start number changes; done rows show
the frozen label.
- **Options card**
- Bezeichnung: preset chips + text input (`maxlength="40"`), live example „Anlage K 6“.
- Startnummer (number input, default 1, min 0) + hint „3 Dateien → Anlage K 6, 7, 8“.
- „Auf jeder Seite stempeln“ checkbox (default off).
- Position (default oben rechts), Schriftgröße slider 10–24 (default 14), Farbe (default
`#1a1a1a`).
- „Dateien umbenennen“ checkbox (default on) with the filename pattern as helper text.
- **Preview card:** selected row, page 1, overlay of the row's label at the chosen position; page
count of the selected file shown (requires loading it — the renderer reports both).
- **Primary action** „Anlagen erstellen“ → `runBatch` over all pending rows; then per-row downloads
and the ZIP action. While running: „Datei 3 von 7 wird verarbeitet…“ (batch pattern) and
reordering disabled.
- Errors: encrypted PDFs become per-row error rows with the shared decryption hint; an empty prefix
blocks the run with a German validation hint; too-long labels use the overlay's fit/error path.
## Technical approach
### Shared overlay and errors
- Uses `drawTextOverlay` from `src/lib/pdf-text-overlay.ts` (introduced by
`plan/done/page-numbers-plan.md`) with `framed: true`, bold font, `compensatePageRotation: true`.
- Uses `src/lib/pdf-errors.ts` for the encrypted-PDF message.
- If the exhibit plan ships first, it creates both modules and the pagination plan reuses them.
### Batch engine
- Extend `runBatch`'s callback to `(file: File, item: BatchItem) => Promise<BatchProcessResult>` so
the tool can resolve the frozen number per row (existing `(file) => …` callbacks remain valid).
- Keep `shouldProcess`, per-row error formatting and the sequential loop unchanged.
- Extract the ZIP/status logic that currently lives inline in `BatchFileList.svelte` into
`src/lib/batch.ts` (`downloadBatchZip(items, zipBaseName)`, `batchStatusLabel`,
`batchStatusClass`) and reuse it from `BatchFileList` and `ExhibitFileList` without behaviour
changes.
### Naming helper (`src/lib/exhibit-naming.ts`)
```ts
export const buildExhibitLabel = (prefix: string, number: number) => `${prefix.trim()} ${number}`;
export const buildExhibitFileName = (options: {
prefix: string;
number: number;
highestNumber: number;
originalName: string;
}): string => {
const base = options.originalName.replace(/\.pdf$/i, '');
const slug = options.prefix
.trim()
.replace(/\s+/g, '_')
.replace(/[/\\:*?"<>|\u0000-\u001f]/g, '');
const width = Math.max(2, String(options.highestNumber).length);
return `${slug}_${String(options.number).padStart(width, '0')}_${base}.pdf`;
};
```
### Stamping
- Load the PDF with pdf-lib, embed `StandardFonts.HelveticaBold`, draw the label on page 0 (or all
pages), save.
- Result name from the naming helper (or the original name when renaming is off). The batch result
keeps the bytes in memory for download/ZIP.
- Numbering is frozen at run start; `process` resolves `numbers.get(item.id) ?? freeze(item)`.
## Files changed
- `src/routes/tools/exhibits/+page.svelte` — new route.
- `src/lib/components/ExhibitFileList.svelte` — reorderable list with status/downloads.
- `src/lib/exhibit-naming.ts` — label + filename helpers.
- `src/lib/batch.ts`, `src/lib/components/BatchFileList.svelte` — `runBatch` item context, shared
ZIP/status helpers.
- `src/lib/pdf-text-overlay.ts`, `src/lib/pdf-errors.ts`, `src/lib/pdf-thumbnails.ts` — shared
helpers (whichever plan ships first creates them).
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map (`Files`).
- `tests/e2e/exhibits.spec.ts` — new spec.
- `agents.md` — route, batch and shared-helper documentation.
## Implementation phases
1. Shared helpers (`pdf-text-overlay`, `pdf-errors`, page renderer) + stamp/compress regression.
2. Batch refactor: `runBatch` item context, ZIP/status extraction, `BatchFileList` unchanged in
behaviour.
3. Naming helper + list component with reorder, live labels and selection.
4. Route: options, stamping, per-row results, ZIP, error rows.
5. Preview panel.
6. Registry/icon/docs.
7. Playwright coverage + command gauntlet.
## Verification
- Three PDFs (1, 2, 3 pages), reorder the second to the front, prefix `Anlage K`, start 6, renaming
on → labels „Anlage K 6/7/8“, downloads named `Anlage_K_06_…`, `Anlage_K_07_…`, `Anlage_K_08_…`;
page 1 of each contains the label, page 2 is untouched (`getPageContentStreamCount` unchanged).
- Start 98 with three files → padding switches to three digits (`098`, `099`, `100`).
- Renaming off → downloads keep the original names; ZIP entries deduplicate on collisions.
- „Auf jeder Seite stempeln“ → every page of every exhibit contains the label.
- Reorder after a run → done rows keep their label and the hint is visible.
- Prefix validation (empty), encrypted PDF (row error, queue continues), 30-file ZIP.
- Single-page PDFs do not break the run.
- `pnpm check`, targeted `pnpm test:e2e`, `pnpm build`, Prettier on touched files.
## Risks
- **Number drift after edits:** mitigated by frozen numbers + hint; no silent renumber.
- **Drag-and-drop accessibility:** buttons remain the primary path and the tested one; DnD must have
a visible handle and keyboard alternative.
- **Memory:** results stay in memory like every batch tool; the existing warning (>20 files or
> 25 MB) applies. The preview renders only one page — never a thumbnail array.
- **Filename edge cases:** slugs from unusual prefixes (`Anlage K/1`) must not produce path
separators or empty names; validate and fall back to `Anlage`.
- **Rotated scans:** the shared overlay compensates page rotation for the new tools; stamp output
must stay unchanged.
- **Already-prefixed input files:** re-running the tool on its own output stacks prefixes. Document
it; no auto-detection in v1.
## Out of scope
- Grouping multiple PDFs into one Anlage.
- Anlagenverzeichnis / index page (a merged table of contents is a follow-up candidate).
- One merged „Anlagenkonvolut“ export.
- Per-page counters (pagination plan).
- Persisted prefixes or exhibit libraries.
+250
View File
@@ -0,0 +1,250 @@
# Plan: Paginierung — fortlaufende Blattnummern mit Aktenzeichen
## Goal
Add a new tool **„Paginieren“** (`/tools/pagination`) that stamps every page of one PDF with an
ascending sheet number — the way authorities paginate a case file („Bl. 26“) — optionally combined
with a static free text (e.g. an Aktenzeichen `123 Ja 60/26`) that repeats on every page (default) or
only on the first page. The same operation is offered as a follow-up action after **„PDFs
zusammenfügen“**.
Context: mail thread with Nikolai Klute (26.09.2026 / 06.10.2026). Two clarifications shape this
plan: pagination needs a _simple_ free text next to the tool-generated number (no `{0}/{1}` template
language), and that text should repeat „auf der ersten oder auf jeder Seite (Default)“.
Related: `plan/done/stamp-plan.md` already references this plan as `page-numbers-plan`.
---
## Open decisions (defaults marked ✅)
### D1 — Free-text model
- ✅ **A. Static free text + tool-generated number.** No placeholder syntax: the number is always
produced by the tool, the free text is an optional label rendered with it. Directly matches
Nikolai's answer („Einfach nur ein Text im Zusammenhang mit der Seitenzahl“).
- B. Template field (`Seite {n} von {total}`). Rejected for v1: more validation and support surface
than the use case needs; `{total}` is not requested.
### D2 — What repeats, what increments
- ✅ **A. Two independent scopes.** Page numbers always on every page (that is the point of
pagination). The free text has its own scope: „Auf jeder Seite“ (default) or „Nur auf der ersten
Seite“ — on page 2+ only the number remains.
- B. One shared scope (text scope also gates numbering). Rejected: a first-page-only pagination is
not a thing.
### D3 — Number format
- ✅ **A. Select with four fixed formats** — `Bl. {n}` (default), `Seite {n}`, `S. {n}`, `{n}`.
Internally a prefix/suffix pair, so no user-visible template language, but „Bl.“ as the
German-authority default. Free text is prepended as `„{Text}, “`.
- B. Free prefix/suffix inputs. More knobs, more invalid combinations.
- C. One free format string. Reopened D1.
### D4 — Look and placement
- ✅ **A. Restrained authority style:** Helvetica, 11 pt (slider 8–18), black `#1a1a1a`, no frame,
no rotation, default position **unten mittig** with 28 pt margin; the six other positions are
selectable via the existing `getPositionCoords` presets.
- B. Reuse the stamp tool's framed/bold look. Wrong semantics: a page number is a neutral mark, not
an authority stamp.
### D5 — Files per run
- ✅ **A. Exactly one PDF.** A Gesamtdokument is paginated in one pass; multi-document cases are
handled by „PDFs zusammenfügen“ + handoff. Continuous numbering across several dropped files is a
different (and silently wrong) semantic.
- B. Batch with the same start number per file. Ambiguous for users; deferred until requested.
### D6 — Integration into „PDF Zusammenfügen“
- ✅ **A. Handoff to the pagination tool.** Merge writes the merged bytes into a `File`, queues it via
the existing in-memory file handoff, and navigates to `/tools/pagination`, where options and live
preview live. One implementation of pagination, preview included.
- B. Inline pagination options on the merge page. Duplicated UI and no preview.
### D7 — Preview renderer
- ✅ **A. On-demand single-page renderer.** A paginated case file can have hundreds of pages;
`loadPdfDocument` rasterises _every_ page up front. A new
`loadPdfPageRenderer(bytes)` renders only the visible page and disposes the previous one.
- B. Reuse `loadPdfDocument` (all thumbnails). Unacceptable for long documents.
---
## UX
Route `/tools/pagination`, title „Paginieren“, registry entry after `stamp`:
```ts
{
slug: 'pagination',
title: 'Paginieren',
description: 'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
icon: 'list-ordered'
}
```
Page layout (stamp tool as the pattern: options left, preview right):
- **FileDropzone** (single PDF) — „PDF zum Paginieren hinzufügen“; shows the file name and page count
after loading.
- **Options card**
- **Startnummer** (number input, default 1, min 1, max 99999) with hint „Seite 1 erhält diese
Nummer.“
- **Zusatztext (optional)** — e.g. Aktenzeichen, `maxlength="60"`, placeholder
„z. B. 123 Ja 60/26“, plus radio/checkbox „Auf jeder Seite“ (default) / „Nur auf der ersten
Seite“.
- **Zahlenformat** select: `Bl. {n}`, `Seite {n}`, `S. {n}`, `{n}`.
- **Position** select (default „Unten mittig“), **Schriftgröße** slider 8–18 pt (default 11).
- Live example line: `123 Ja 60/26, Bl. 26`.
- **Preview card** — the current page rendered at reading size with an absolutely positioned overlay
(same technique as the stamp preview), page counter „Seite 3 von 42“, prev/next buttons and a
„Springe zu Seite“ number input (long documents). The overlay shows the number that page will
actually receive (`start + pageIndex`). Label it „Annäherung an die Ausgabe im PDF“.
- **Primary action** „Paginieren & herunterladen“ → `{base}_paginiert.pdf`. Options are read at click
time, so the button can be pressed again after changing settings.
- Errors: encrypted PDF → the shared German hint pointing to „Passwort entfernen“; text too long for
a page → German error instead of silent clipping.
## Technical approach
### Shared text overlay (`src/lib/pdf-text-overlay.ts`)
Extract the reusable parts of `src/routes/tools/stamp/+page.svelte` into a module used by stamp,
pagination and exhibit numbering:
```ts
export type TextOverlayOptions = {
font: PDFFont;
text: string;
size: number;
color: RGB;
position: OverlayPosition;
margin?: number;
padding?: number;
framed?: boolean;
borderWidth?: number;
rotation?: number; // degrees
minSize?: number; // shrink floor before throwing
compensatePageRotation?: boolean; // default false; new tools set true
};
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions) => void;
```
- Owns fit-to-page shrinking (loop down to `minSize`), the rotated placement maths already present in
the stamp route (`getRotatedStampPlacement`), optional border, and the text draw.
- The stamp route is rewired to call it with `framed: true`, `rotation: value` and its bold font;
behaviour must stay identical (guard: `tests/e2e/stamp.spec.ts`).
- `compensatePageRotation` maps bottom-center on a `/Rotate 90` scan to the correct user-space anchor
and counter-rotates the text. Stamp keeps `false` so its output does not change.
- Move `isEncryptedPdfError` / the encrypted-PDF message into `src/lib/pdf-errors.ts` so all three
tools share one wording.
### Pagination
```ts
const numberText = `${formatPrefix}${startNumber + pageIndex}${formatSuffix}`;
const textVisible = textScope === 'all' || pageIndex === 0;
const line = textVisible && freeText.trim() ? `${freeText.trim()}, ${numberText}` : numberText;
for (const [pageIndex, page] of pdfDoc.getPages().entries()) {
drawTextOverlay(page, {
font,
text: line,
size,
color: parseHexColor('#1a1a1a'),
position,
margin: 28,
compensatePageRotation: true
});
}
```
- Embed one `StandardFonts.Helvetica` per document, save, name `${base}_paginiert.pdf`.
- Page count comes from pdfjs (`numPages`) for the preview and from pdf-lib for processing; both are
cheap, no full rasterisation.
- **Renderer:** extend `src/lib/pdf-thumbnails.ts` with
`loadPdfPageRenderer(bytes): Promise<{ pageCount, render(pageNumber), destroy() }>`; `render`
builds the canvas from the requested page's own viewport (mixed page sizes stay correct), revokes
the previous object URL, and `destroy` tears down the pdfjs loading task. Reused by the exhibit
tool's page-1 preview.
### Merge integration
- Generalise `src/lib/pending-files.ts`: `queueZipFiles` / `takeQueuedZipFiles` become
`queuePendingFiles` / `takePendingFiles` (same behaviour; update `src/routes/+page.svelte` and
`src/routes/tools/bea/+page.svelte`).
- `src/routes/tools/merge/+page.svelte`: keep „PDFs zusammenfügen“ (direct download) and add
„Zusammenfügen & paginieren“, which merges, wraps the bytes in
`new File([bytes], 'zusammengefuegt.pdf', { type: 'application/pdf' })`, queues it and calls
`goto('/tools/pagination')`. Keep the change minimal — the page still uses older styling tokens;
do not restyle it in this task.
- The pagination route consumes `takePendingFiles()` in `onMount`, accepts the first PDF and ignores
anything else defensively.
### Registry and docs
- `src/lib/tools.ts` + `iconMap` in `src/routes/+page.svelte` (`ListOrdered`).
- `agents.md`: new route, shared overlay/error helpers, renderer, pending-file naming, merge handoff.
## Files changed
- `src/routes/tools/pagination/+page.svelte` — new route.
- `src/lib/pdf-text-overlay.ts`, `src/lib/pdf-errors.ts` — extracted shared helpers.
- `src/routes/tools/stamp/+page.svelte` — use the shared helpers (no behaviour change).
- `src/lib/pdf-thumbnails.ts` — add `loadPdfPageRenderer`.
- `src/lib/pending-files.ts`, `src/routes/+page.svelte`, `src/routes/tools/bea/+page.svelte` —
generic pending-file naming.
- `src/routes/tools/merge/+page.svelte` — handoff button.
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
- `tests/e2e/pagination.spec.ts`, `tests/e2e/merge.spec.ts` — new/updated specs.
- `agents.md` — structure and flow documentation.
## Implementation phases
1. Extract `pdf-text-overlay.ts` + `pdf-errors.ts`, rewire stamp, keep `stamp.spec.ts` green.
2. Add `loadPdfPageRenderer` and use it for the preview.
3. Build the pagination route (options, live preview, export, errors).
4. Merge handoff + generic pending files.
5. Registry/icon/docs.
6. Playwright coverage + command gauntlet.
## Verification
- 3-page PDF, start 26, text `123 Ja 60/26`, all pages → each page's content stream contains
`123 Ja 60/26, Bl. 26/27/28`; page count and page order preserved; download named
`akte_paginiert.pdf`.
- First-page-only text → page 1 contains the Aktenzeichen, page 2 contains `Bl. 2` but not the
Aktenzeichen; the original page content is preserved on every page.
- Formats `Seite {n}` / `{n}` and empty free text → expected strings.
- Preview: navigating to page 2 shows `Bl. 27` (start 26); changing the start number updates the
preview before export.
- Rotated input (`createPreRotatedPdf`) → number appears at the visual bottom-center (test asserts
the draw coordinates in the content stream or compares stream text plus position).
- Merge: two PDFs (1 + 2 pages) → „Zusammenfügen & paginieren“ lands on `/tools/pagination` with a
3-page document; running produces continuous `Bl. 1…3`.
- Encrypted PDF → German error, no download.
- `pnpm check`, targeted `pnpm test:e2e`, `pnpm build`, Prettier on touched files.
## Risks
- **Long documents:** never call `loadPdfDocument` here; one page in memory at a time. The preview
navigation must not leak object URLs or pdfjs tasks.
- **Text length on narrow pages:** fit-to-page shrinks to `minSize`; below that, fail with a German
message rather than clipping.
- **Unsupported characters:** StandardFonts use WinAnsi; validate the free text (e.g. no emoji) and
show a German hint instead of a pdf-lib save error.
- **Rotated pages:** compensation is new logic; if the mapping turns out unreliable, ship v1 with a
visible warning for rotated pages instead of silently misplaced numbers.
- **Preview fidelity:** browser font metrics differ slightly from pdf-lib; the panel is labelled as
an approximation.
## Out of scope
- Placeholder templates (`{total}`, Roman numerals, ranges) and numbering only selected pages.
- Multiple files per run with continuous numbering (use merge first).
- Persisted pagination settings.
- Automatic pagination inside the beA archive export.
+2 -1
View File
@@ -8,7 +8,8 @@ const chromiumExecutable =
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
export default defineConfig({
testDir: './tests/e2e',
// `tests/unit` holds pure-function specs (no browser); Playwright runs both.
testDir: './tests',
fullyParallel: false,
retries: 0,
reporter: 'line',
+841 -804
View File
File diff suppressed because it is too large. Load diff
+97 -5
View File
@@ -1,3 +1,6 @@
import { zipSync } from 'fflate';
import { downloadBlob } from './download';
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
export type BatchItem = {
@@ -12,8 +15,11 @@ export type BatchItem = {
export type BatchProcessResult = { bytes: Uint8Array; name: string };
export type BatchRunOptions = {
/** Error message written to a row when `process` throws for it. */
errorMessage?: string;
/**
* Error message written to a row when `process` throws for it. A function
* receives the thrown error so tools can surface a specific German message.
*/
errorMessage?: string | ((error: unknown, file: File) => string);
/**
* Optional predicate consulted before each item; when it returns `false`
* (for example because the row was removed mid-run), the item is skipped.
@@ -31,10 +37,13 @@ export const createBatchItem = (file: File): BatchItem => ({
* Processes the given items strictly one after another so only one PDF is in
* flight at a time. Never throws: a failing item is marked as an error row and
* the queue continues with the remaining files.
*
* `process` receives the item as well, so tools can resolve per-row data (for
* example the exhibit number frozen when the run started).
*/
export const runBatch = async (
items: BatchItem[],
process: (file: File) => Promise<BatchProcessResult>,
process: (file: File, item: BatchItem) => Promise<BatchProcessResult>,
update: (id: string, patch: Partial<BatchItem>) => void,
options: BatchRunOptions = {}
): Promise<void> => {
@@ -45,7 +54,7 @@ export const runBatch = async (
update(item.id, { status: 'processing', errorMessage: null });
try {
const result = await process(item.file);
const result = await process(item.file, item);
update(item.id, {
status: 'done',
resultBytes: result.bytes,
@@ -58,7 +67,8 @@ export const runBatch = async (
status: 'error',
resultBytes: undefined,
resultName: undefined,
errorMessage
errorMessage:
typeof errorMessage === 'function' ? errorMessage(error, item.file) : errorMessage
});
}
}
@@ -69,3 +79,85 @@ export const formatBytes = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
/** Badge colours for a batch row, shared by every batch list component. */
export const batchStatusClass = (status: BatchItemStatus) => {
switch (status) {
case 'processing':
return 'bg-amber-100 text-amber-800';
case 'done':
return 'bg-emerald-100 text-emerald-800';
case 'error':
return 'bg-red-100 text-red-800';
default:
return 'bg-accent text-primary';
}
};
/** German badge label for a batch row. */
export const batchStatusLabel = (status: BatchItemStatus) => {
switch (status) {
case 'processing':
return 'Verarbeite…';
case 'done':
return 'Fertig ✓';
case 'error':
return 'Fehler';
default:
return 'Wartet';
}
};
/** Above this many files (or one file above the threshold) memory gets tight. */
const BATCH_MEMORY_FILE_COUNT = 20;
const BATCH_MEMORY_FILE_BYTES = 25 * 1024 * 1024;
/**
* Shared memory hint for batch tools: very many or very large files keep every
* result in memory until the page is closed.
*/
export const shouldWarnAboutBatchMemory = (items: BatchItem[]) =>
items.length > BATCH_MEMORY_FILE_COUNT ||
items.some((item) => item.file.size > BATCH_MEMORY_FILE_BYTES);
/** Appends `_2`, `_3`, … when a name is already taken inside the archive. */
export const uniqueEntryName = (name: string, usedNames: Set<string>) => {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
let counter = 2;
let candidate = `${base}_2${extension}`;
while (usedNames.has(candidate)) {
counter += 1;
candidate = `${base}_${counter}${extension}`;
}
usedNames.add(candidate);
return candidate;
};
/**
* Bundles every finished row into one `{zipBaseName}_{YYYY-MM-DD}.zip`. Kept out
* of the component so the next batch tool (exhibits) reuses the naming and the
* duplicate handling instead of copying them.
*/
export const downloadBatchZip = (items: BatchItem[], zipBaseName: string) => {
const entries: Record<string, Uint8Array> = {};
const usedNames = new Set<string>();
for (const item of items) {
if (item.status !== 'done' || !item.resultBytes || !item.resultName) continue;
entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes;
}
if (Object.keys(entries).length === 0) return;
const dateStamp = new Date().toISOString().slice(0, 10);
downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip');
};
+5 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pdfLoadErrorMessage } from '$lib/pdf-errors';
import type { ZipAttachment } from '$lib/zip-processing';
@@ -98,7 +99,7 @@
await loadingTask.destroy();
} catch (error) {
console.error(`Failed to render preview for ${attachment.path}`, error);
previewError = 'PDF-Vorschau konnte nicht geladen werden.';
previewError = pdfLoadErrorMessage(error, 'PDF-Vorschau konnte nicht geladen werden.');
}
};
@@ -115,19 +116,19 @@
<div class={className}>
{#if previewError}
<div
class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
class="border-primary-200 bg-primary-50 text-primary-800 flex h-full min-h-0 items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
>
{previewError}
</div>
{:else if imageUrl}
<img
alt={attachment.name}
class="block rounded-md border border-primary-100 bg-white object-contain"
class="border-primary-100 block rounded-md border bg-white object-contain"
src={imageUrl}
/>
{:else}
<div
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
class="border-primary-200 bg-primary-50 text-primary-800 flex items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
>
Vorschau wird erstellt
</div>
+16 -68
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
import { zipSync } from 'fflate';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { formatBytes, type BatchItem } from '$lib/batch';
import { RASTERIZATION_WARNING_BYTES } from '$lib/rasterization-limits';
import {
batchStatusClass,
batchStatusLabel,
downloadBatchZip,
formatBytes,
shouldWarnAboutBatchMemory,
type BatchItem
} from '$lib/batch';
type Props = {
items: BatchItem[];
@@ -22,68 +26,12 @@
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
);
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
const showMemoryWarning = $derived(
items.length > 20 || items.some((item) => item.file.size > RASTERIZATION_WARNING_BYTES)
);
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
const uniqueEntryName = (name: string, usedNames: Set<string>) => {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
let counter = 2;
let candidate = `${base}_2${extension}`;
while (usedNames.has(candidate)) {
counter += 1;
candidate = `${base}_${counter}${extension}`;
}
usedNames.add(candidate);
return candidate;
};
const downloadZip = () => {
const entries: Record<string, Uint8Array> = {};
const usedNames = new Set<string>();
for (const item of doneItems) {
if (!item.resultBytes || !item.resultName) continue;
entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes;
}
if (Object.keys(entries).length === 0) return;
const dateStamp = new Date().toISOString().slice(0, 10);
downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip');
};
const statusChipClass = (status: BatchItem['status']) => {
switch (status) {
case 'processing':
return 'bg-amber-100 text-amber-800';
case 'done':
return 'bg-emerald-100 text-emerald-800';
case 'error':
return 'bg-red-100 text-red-800';
default:
return 'bg-accent text-primary';
}
};
const statusLabel = (status: BatchItem['status']) => {
switch (status) {
case 'processing':
return 'Verarbeite…';
case 'done':
return 'Fertig ✓';
case 'error':
return 'Fehler';
default:
return 'Wartet';
}
};
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
</script>
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-foreground">Dateien ({items.length})</h2>
{#if doneItems.length > 0}
@@ -123,18 +71,18 @@
</div>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {statusChipClass(
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
item.status
)}"
>
{statusLabel(item.status)}
{batchStatusLabel(item.status)}
</span>
<div class="flex shrink-0 items-center gap-1">
{#if item.status === 'done' && item.resultBytes && item.resultName}
<button
type="button"
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
aria-label="{item.file.name} herunterladen"
title="{item.file.name} herunterladen"
onclick={() => onDownload(item)}
@@ -145,7 +93,7 @@
{#if item.status === 'error'}
<button
type="button"
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
aria-label="{item.file.name} erneut versuchen"
title="{item.file.name} erneut versuchen"
onclick={() => onRetry(item.id)}
@@ -155,7 +103,7 @@
{/if}
<button
type="button"
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-40"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} entfernen"
title="{item.file.name} entfernen"
disabled={item.status === 'processing'}
+269
View File
@@ -0,0 +1,269 @@
<script lang="ts">
import { Download, Eye, GripVertical, RotateCcw, Trash2 } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import {
batchStatusClass,
batchStatusLabel,
downloadBatchZip,
formatBytes,
shouldWarnAboutBatchMemory,
type BatchItem
} from '$lib/batch';
type Props = {
items: BatchItem[];
/** Resolved stamp label per row; frozen once the row was created. */
labels: Record<string, string>;
/** Renamed file per row, or `null` when renaming is switched off. */
fileNames: Record<string, string | null>;
selectedId: string | null;
/** Base file name of the bulk ZIP, e.g. `anlagen` → `anlagen_2026-10-06.zip`. */
zipBaseName: string;
/** Reordering and preview selection are blocked while a run is active. */
disabled?: boolean;
onSelect: (id: string) => void;
onMove: (from: number, to: number) => void;
onDownload: (item: BatchItem) => void;
onRemove: (id: string) => void;
onRetry: (id: string) => void;
};
let {
items,
labels,
fileNames,
selectedId,
zipBaseName,
disabled = false,
onSelect,
onMove,
onDownload,
onRemove,
onRetry
}: Props = $props();
let draggingId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
const doneItems = $derived(items.filter((item) => item.status === 'done'));
const totalResultBytes = $derived(
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
);
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
const moveUp = (index: number) => {
if (disabled || index === 0) return;
onMove(index, index - 1);
};
const moveDown = (index: number) => {
if (disabled || index === items.length - 1) return;
onMove(index, index + 1);
};
const handleDragStart = (event: DragEvent, item: BatchItem) => {
if (disabled) return;
draggingId = item.id;
event.dataTransfer?.setData('text/plain', item.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (event: DragEvent, item: BatchItem) => {
if (disabled || !draggingId || draggingId === item.id) return;
event.preventDefault();
dragOverId = item.id;
};
const handleDrop = (event: DragEvent, item: BatchItem) => {
event.preventDefault();
const sourceId = draggingId;
draggingId = null;
dragOverId = null;
if (disabled || !sourceId || sourceId === item.id) return;
const from = items.findIndex((entry) => entry.id === sourceId);
const to = items.findIndex((entry) => entry.id === item.id);
if (from === -1 || to === -1) return;
onMove(from, to);
};
const handleDragEnd = () => {
draggingId = null;
dragOverId = null;
};
</script>
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-foreground">Anlagen ({items.length})</h2>
{#if doneItems.length > 0}
<p class="text-sm text-primary">
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
</p>
{/if}
</div>
{#if processingIndex !== -1}
<p class="text-sm text-primary" role="status">
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
</p>
{/if}
{#if showMemoryWarning}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
</div>
{/if}
<ul class="divide-y divide-border">
{#each items as item, index (item.id)}
{@const label = labels[item.id] ?? ''}
{@const renamed = fileNames[item.id] ?? null}
<li
class="flex flex-wrap items-center gap-3 py-3 {dragOverId === item.id
? 'bg-accent/40'
: ''} {draggingId === item.id ? 'opacity-60' : ''}"
data-exhibit-item={item.file.name}
data-exhibit-index={index + 1}
data-exhibit-label={label}
draggable={!disabled}
ondragstart={(event) => handleDragStart(event, item)}
ondragover={(event) => handleDragOver(event, item)}
ondrop={(event) => handleDrop(event, item)}
ondragend={handleDragEnd}
>
<div class="flex shrink-0 items-center gap-1">
<span
class="cursor-grab p-1 text-muted-foreground {disabled ? 'opacity-40' : ''}"
aria-hidden="true"
title="Ziehen zum Verschieben"
>
<GripVertical class="h-4 w-4" />
</span>
<span
class="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold text-primary"
title="Position in der Liste"
>
{index + 1}
</span>
<div class="flex flex-col">
<button
type="button"
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} nach oben verschieben"
title="Nach oben"
disabled={disabled || index === 0}
onclick={() => moveUp(index)}
>
<svg
viewBox="0 0 24 24"
class="h-3 w-3"
fill="none"
stroke="currentColor"
stroke-width="3"
aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg
>
</button>
<button
type="button"
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} nach unten verschieben"
title="Nach unten"
disabled={disabled || index === items.length - 1}
onclick={() => moveDown(index)}
>
<svg
viewBox="0 0 24 24"
class="h-3 w-3"
fill="none"
stroke="currentColor"
stroke-width="3"
aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg
>
</button>
</div>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">
{label}
</p>
<p class="truncate text-xs text-primary">{item.file.name}</p>
{#if renamed}
<p class="truncate text-xs text-muted-foreground" data-exhibit-filename>
→ {renamed}
</p>
{/if}
{#if item.status === 'error' && item.errorMessage}
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
{/if}
</div>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
item.status
)}"
>
{batchStatusLabel(item.status)}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40 aria-pressed:bg-accent"
aria-label="Vorschau für {item.file.name}"
title="Vorschau für {item.file.name}"
aria-pressed={selectedId === item.id}
{disabled}
onclick={() => onSelect(item.id)}
>
<Eye class="h-4 w-4" />
</button>
{#if item.status === 'done' && item.resultBytes && item.resultName}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
aria-label="{item.file.name} herunterladen"
title="{item.file.name} herunterladen"
onclick={() => onDownload(item)}
>
<Download class="h-4 w-4" />
</button>
{/if}
{#if item.status === 'error'}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} erneut versuchen"
title="{item.file.name} erneut versuchen"
{disabled}
onclick={() => onRetry(item.id)}
>
<RotateCcw class="h-4 w-4" />
</button>
{/if}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} entfernen"
title="{item.file.name} entfernen"
disabled={item.status === 'processing'}
onclick={() => onRemove(item.id)}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</li>
{/each}
</ul>
{#if doneItems.length >= 2}
<div class="flex justify-end pt-1">
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
</div>
{/if}
</div>
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import { MIN_FONT_SIZE, PAGE_NUMBER_COLOR, PAGE_NUMBER_MARGIN } from '$lib/pdf-pagination';
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
import { getTextFitScale } from '$lib/pdf-preview-text';
import { PDF_PREVIEW_SCALE, type PdfPageImage } from '$lib/pdf-thumbnails';
type Props = {
/** Rendered page bitmap, or `null` while the first render is pending. */
preview: PdfPageImage | null;
/** Message shown instead of the bitmap when rendering failed. */
error: string | null;
/** Label the export will stamp on this page. */
label: string;
fontSize: number;
position: OverlayPosition;
currentPage: number;
pageCount: number;
onRetry: () => void;
onShowPage: (pageNumber: number) => void;
};
let {
preview,
error,
label,
fontSize,
position,
currentPage,
pageCount,
onRetry,
onShowPage
}: Props = $props();
const pageSize = $derived(
preview
? { width: preview.width / PDF_PREVIEW_SCALE, height: preview.height / PDF_PREVIEW_SCALE }
: null
);
/**
* The export shrinks a label that does not fit (down to `MIN_FONT_SIZE`), so
* the preview applies the same factor instead of clipping.
*/
const fit = $derived.by(() => {
if (!pageSize) return { scale: 1, shrunk: false, tooLong: false };
const raw = getTextFitScale(
label,
fontSize,
pageSize.width - PAGE_NUMBER_MARGIN * 2,
pageSize.height - PAGE_NUMBER_MARGIN * 2
);
const minScale = MIN_FONT_SIZE / fontSize;
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
});
const previewFontSize = $derived(
preview ? (fontSize * fit.scale * PDF_PREVIEW_SCALE * 100) / preview.width : 0
);
const shrunkFontSize = $derived(Math.max(MIN_FONT_SIZE, Math.round(fontSize * fit.scale)));
</script>
<section
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
aria-labelledby="pagination-preview-title"
>
<div class="flex items-start justify-between gap-3">
<div>
<h2 id="pagination-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
</div>
<span class="text-xs font-semibold text-primary">Seite {currentPage} von {pageCount}</span>
</div>
{#if error}
<div
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
role="alert"
>
<p class="text-sm text-foreground">{error}</p>
<Button variant="tertiary" onclick={onRetry}>Erneut versuchen</Button>
</div>
{:else if preview}
<div
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
>
<img
src={preview.imageUrl}
alt="Vorschau von Seite {currentPage}"
class="block h-full w-full"
/>
<div
class="absolute max-w-[92%] leading-tight whitespace-nowrap"
style="{getPreviewPositionStyle(
position
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_COLOR};"
data-testid="pagination-preview"
data-position={position}
>
{label}
</div>
</div>
{:else}
<div
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
>
Vorschau wird geladen …
</div>
{/if}
{#if fit.tooLong}
<p class="text-xs text-destructive" role="status">
Der Text ist für diese Seite zu lang. Kürzen Sie den Zusatztext oder wählen Sie ein kürzeres
Zahlenformat.
</p>
{:else if fit.shrunk}
<p class="text-xs text-muted-foreground" role="status">
Auf dieser Seite wird der Text auf {shrunkFontSize} pt verkleinert.
</p>
{/if}
{#if pageCount > 1}
<div class="flex flex-wrap items-center justify-center gap-3">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => onShowPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => onShowPage(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Nächste Seite"
>
<ChevronRight aria-hidden="true" />
</Button>
<label class="flex items-center gap-2 text-sm text-foreground" for="pagination-page">
<span class="text-xs text-muted-foreground">Seite</span>
<input
id="pagination-page"
type="number"
inputmode="numeric"
step="1"
min="1"
max={pageCount}
value={currentPage}
oninput={(event) => onShowPage(Number(event.currentTarget.value))}
onblur={(event) => (event.currentTarget.value = String(currentPage))}
aria-label="Springe zu Seite"
class="w-20 rounded-lg border border-input bg-background px-2 py-1 text-sm"
/>
</label>
</div>
{/if}
</section>
+21 -20
View File
@@ -3,6 +3,7 @@
import { X } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
import {
createAttachmentFromPages,
@@ -172,7 +173,7 @@
onApply(await buildResult(groups));
} catch (error) {
console.error(`Failed to split PDF attachment ${source.path}`, error);
splitError = 'Das PDF konnte nicht geteilt werden.';
splitError = pdfProcessingErrorMessage(error, 'Das PDF konnte nicht geteilt werden.');
} finally {
isProcessing = false;
}
@@ -209,7 +210,7 @@
console.error(`Failed to load PDF attachment ${source.path}`, error);
if (!cancelled) {
loadError = 'PDF konnte nicht geladen werden.';
loadError = pdfLoadErrorMessage(error);
}
}
})();
@@ -237,16 +238,16 @@
data-split-dialog
tabindex="-1"
>
<header class="flex items-center justify-between gap-3 border-b border-primary-100 px-5 py-4">
<header class="border-primary-100 flex items-center justify-between gap-3 border-b px-5 py-4">
<div class="min-w-0">
<h2 class="text-[16px] font-bold text-primary-900">PDF teilen</h2>
<p class="truncate text-[13px] text-primary-700" title={source.name}>
<h2 class="text-primary-900 text-[16px] font-bold">PDF teilen</h2>
<p class="text-primary-700 truncate text-[13px]" title={source.name}>
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
</p>
</div>
<button
type="button"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50"
class="border-primary-200 text-primary-700 hover:border-primary-400 hover:bg-primary-50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border bg-white transition"
aria-label="Teilen abbrechen"
onclick={onClose}
>
@@ -256,7 +257,7 @@
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div
class="inline-flex flex-wrap items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
class="border-primary-200 inline-flex flex-wrap items-center rounded-[16px] border bg-white p-0.5"
role="radiogroup"
aria-label="Art des Teilens"
>
@@ -283,10 +284,10 @@
</div>
{#if mode === 'everyN'}
<label class="flex flex-wrap items-center gap-2 text-[13px] font-semibold text-primary-900">
<label class="text-primary-900 flex flex-wrap items-center gap-2 text-[13px] font-semibold">
Seiten pro Teildokument
<input
class="w-20 rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-20 rounded-md border bg-white px-2 py-1 text-[13px] font-medium transition outline-none focus:ring-2"
type="number"
min={2}
value={chunkSize}
@@ -298,7 +299,7 @@
{/if}
{#if mode !== 'manual' && pageCount > 0}
<p class="text-[13px] font-medium text-primary-700" aria-live="polite">
<p class="text-primary-700 text-[13px] font-medium" aria-live="polite">
Es werden {bulkChunks.length}
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
</p>
@@ -329,7 +330,7 @@
</PdfPageGrid>
{:else}
<div
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-10 text-[13px] text-primary-700"
class="border-primary-200 bg-primary-50 text-primary-700 flex items-center justify-center rounded-md border border-dashed px-4 py-10 text-[13px]"
>
Vorschau wird erstellt
</div>
@@ -340,7 +341,7 @@
<label class="flex min-w-0 flex-1 items-center gap-2">
<span class="sr-only">Name des Teildokuments</span>
<input
class="w-full min-w-40 rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[13px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full min-w-40 rounded-md border bg-white px-3 py-1.5 text-[13px] font-semibold transition outline-none focus:ring-2"
type="text"
placeholder={selectedPages.size > 0
? defaultGroupName([...selectedPages])
@@ -359,7 +360,7 @@
Als Teildokument übernehmen
</Button>
</div>
<p class="text-[13px] text-primary-700" aria-live="polite">
<p class="text-primary-700 text-[13px]" aria-live="polite">
{selectedPages.size} von {pageCount} Seiten ausgewählt
</p>
{/if}
@@ -368,17 +369,17 @@
<ul class="flex flex-col gap-2">
{#each assignedGroups as group, index (index)}
<li
class="flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50 px-3 py-1.5"
class="border-primary-200 bg-primary-50 flex flex-wrap items-center gap-2 rounded-xl border px-3 py-1.5"
>
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-primary-900">
<span class="text-primary-900 min-w-0 flex-1 truncate text-[13px] font-semibold">
{group.name}
</span>
<span class="text-[12px] font-medium text-primary-700">
<span class="text-primary-700 text-[12px] font-medium">
{describePages(group.pages)}
</span>
<button
type="button"
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-primary-600 transition hover:bg-white hover:text-red-700"
class="text-primary-600 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition hover:bg-white hover:text-red-700"
aria-label={`Zuordnung „${group.name}“ aufheben`}
onclick={() => removeGroup(index)}
>
@@ -395,9 +396,9 @@
</div>
{/if}
<label class="flex items-center gap-2 text-[13px] font-medium text-primary-900">
<label class="text-primary-900 flex items-center gap-2 text-[13px] font-medium">
<input
class="h-4 w-4 accent-primary-700"
class="accent-primary-700 h-4 w-4"
type="checkbox"
checked={keepSource}
onchange={(event) => {
@@ -409,7 +410,7 @@
</div>
<footer
class="flex flex-wrap items-center justify-end gap-2 border-t border-primary-100 px-5 py-4"
class="border-primary-100 flex flex-wrap items-center justify-end gap-2 border-t px-5 py-4"
>
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
Abbrechen
@@ -5,6 +5,7 @@
import Button from '$lib/components/ui/button.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import {
applyPdfSplit,
@@ -447,7 +448,7 @@
}
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
mergeError = 'PDF konnte nicht erstellt werden.';
mergeError = pdfProcessingErrorMessage(error, 'PDF konnte nicht erstellt werden.');
} finally {
isMerging = false;
}
@@ -470,7 +471,10 @@
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
error
);
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
mergeError = pdfProcessingErrorMessage(
error,
`Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`
);
} finally {
downloadingSubDocumentId = null;
}
@@ -480,14 +484,14 @@
<svelte:window onkeydown={handleWindowKeydown} />
<section
class="flex flex-col gap-4 rounded-2xl border border-primary-100 bg-white px-4 py-4 shadow-sm"
class="border-primary-100 flex flex-col gap-4 rounded-2xl border bg-white px-4 py-4 shadow-sm"
>
<header class="flex flex-wrap items-baseline justify-between gap-2 text-primary">
<div class="min-w-0 flex-1">
<label class="block min-w-0">
<span class="sr-only">Archivname</span>
<input
class="w-full max-w-xl rounded-md border border-primary-200 bg-white px-3 py-2 text-[16px] font-bold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full max-w-xl rounded-md border bg-white px-3 py-2 text-[16px] font-bold transition outline-none focus:ring-2"
type="text"
value={archiveName}
draggable="false"
@@ -532,7 +536,7 @@
{#if hasSubDocuments}
<div
class={cn(
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
'border-primary-200 inline-flex items-center rounded-[16px] border bg-white p-0.5',
downloadingSubDocumentId !== null ? 'opacity-60' : ''
)}
role="radiogroup"
@@ -584,7 +588,7 @@
{#if groupingMode}
<div
class="flex flex-wrap items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-2 text-[13px] font-medium text-primary-900"
class="border-primary-200 bg-primary-50 text-primary-900 flex flex-wrap items-center gap-3 rounded-xl border px-4 py-2 text-[13px] font-medium"
>
<span aria-live="polite">
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
@@ -634,10 +638,10 @@
{#each looseAttachments as attachment, index (attachment.path)}
<li
class={cn(
'flex flex-col shrink-0 items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
dropIndex === index ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
'flex shrink-0 flex-col items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
dropIndex === index ? 'border-primary-400 ring-primary-200 ring-1' : 'border-primary-100',
dragIndex === index ? 'opacity-60' : '',
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-primary-300 ring-2' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable={groupingMode ? 'false' : 'true'}
@@ -648,9 +652,9 @@
>
{#if groupingMode}
<label class="flex cursor-pointer flex-col gap-2">
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
<div class="text-primary-900 flex items-center gap-2 px-1 text-[13px] font-medium">
<input
class="h-4 w-4 accent-primary-700"
class="accent-primary-700 h-4 w-4"
type="checkbox"
checked={selectedPaths.has(attachment.path)}
onchange={() => toggleSelection(attachment.path)}
@@ -661,7 +665,7 @@
</div>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
class="text-primary-900 truncate text-[13px] leading-tight font-medium"
title={attachment.name}
>
{attachment.name}
@@ -671,7 +675,7 @@
{:else}
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
class="border-primary-200 text-primary-700 w-fit cursor-grab rounded-md border bg-white px-2 py-1 text-sm leading-none font-semibold active:cursor-grabbing"
draggable="false"
type="button"
aria-label="Datei verschieben"
@@ -680,7 +684,7 @@
</button>
<div
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
class="text-primary-900 min-w-0 flex-1 truncate text-[13px] leading-tight font-medium"
title={attachment.name}
>
{attachment.name}
@@ -703,7 +707,7 @@
{#if looseAttachments.length === 0 && subDocuments.length === 0}
<div
class="rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-4 text-[13px] text-primary-700"
class="border-primary-200 bg-primary-50 text-primary-700 rounded-md border border-dashed px-4 py-4 text-[13px]"
>
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
</div>
@@ -758,7 +762,7 @@
}}
>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-base font-bold text-primary-900">
<AlertDialog.Title class="text-primary-900 text-base font-bold">
Datei entfernen?
</AlertDialog.Title>
<AlertDialog.Description>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import ToolError from '$lib/components/ToolError.svelte';
type Props = {
title: string;
description: string;
file: File | null;
/** `0` while no PDF is loaded; switches the shell to the bare dropzone. */
pageCount: number;
loadingError: string | null;
error?: string | null;
onFilesSelected: (files: File[]) => void;
/** Extra toolbar buttons next to the file name. */
actions?: Snippet;
/** Main content, e.g. the page grid. */
children: Snippet;
/** Status line plus primary action, laid out `space-between`. */
footer: Snippet;
};
let {
title,
description,
file,
pageCount,
loadingError,
error,
onFilesSelected,
actions,
children,
footer
}: Props = $props();
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-primary-900 text-2xl font-bold">{title}</h1>
<p class="text-primary-700 mt-2 text-sm">{description}</p>
</div>
{#if pageCount === 0}
<FileDropzone {onFilesSelected} />
<ToolError message={loadingError} class="mt-4" />
{:else}
<div class="space-y-4">
<FileDropzone
{onFilesSelected}
label="Anderes PDF ablegen"
sublabel={file?.name ?? ''}
class="py-4"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-primary-900 text-lg font-semibold">
{file?.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
</h2>
<div class="flex flex-wrap gap-2">
{@render actions?.()}
</div>
</div>
{@render children()}
<ToolError message={error} />
<div class="flex items-center justify-between">
{@render footer()}
</div>
</div>
{/if}
</div>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils';
type Props = {
/** German message; nothing is rendered when the value is empty. */
message?: string | null;
class?: string;
};
let { message, class: className = '' }: Props = $props();
</script>
{#if message}
<div
class={cn('rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800', className)}
role="alert"
>
{message}
</div>
{/if}
+20 -14
View File
@@ -1,19 +1,12 @@
export const downloadBlob = (
bytes: Uint8Array,
fileName: string,
mimeType: string = 'application/pdf'
) => {
/**
* Copies PDF bytes into a tightly sized buffer before wrapping them in a Blob.
* pdf-lib returns a `Uint8Array` that may be a view into a larger buffer, so the
* copy is required — one place decides this instead of every tool doing it.
*/
export const pdfBytesToBlob = (bytes: Uint8Array, mimeType: string = 'application/pdf'): Blob => {
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);
return new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
};
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
@@ -23,3 +16,16 @@ export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
downloadLink.rel = 'noopener';
downloadLink.click();
};
/** Triggers a browser download for an existing Blob. */
export const downloadBlobFile = (blob: Blob, fileName: string) => {
const objectUrl = URL.createObjectURL(blob);
downloadBlobUrl(objectUrl, fileName);
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
};
export const downloadBlob = (
bytes: Uint8Array,
fileName: string,
mimeType: string = 'application/pdf'
) => downloadBlobFile(pdfBytesToBlob(bytes, mimeType), fileName);
+73
View File
@@ -0,0 +1,73 @@
/**
* Naming helpers for the exhibit numbering tool. Kept pure so the label the
* stamp draws and the file name the download gets can be tested without pdf-lib.
*/
/** Longest allowed exhibit designation (matches the input's `maxlength`). */
export const MAX_EXHIBIT_PREFIX_LENGTH = 40;
/** Designation used when the user leaves the field empty. */
export const DEFAULT_EXHIBIT_PREFIX = 'Anlage';
/** Characters that must never reach a file name on Windows, macOS or Linux. */
const UNSAFE_FILE_NAME_CHARS = /[/\\:*?"<>|\u0000-\u001f]/g;
export const MIN_EXHIBIT_START_NUMBER = 0;
export const MAX_EXHIBIT_START_NUMBER = 99999;
export const DEFAULT_EXHIBIT_START_NUMBER = 1;
/**
* Clamps a typed start number. Empty or non-numeric input (a field being
* cleared) falls back to the default instead of producing `NaN` labels.
*/
export const normalizeExhibitStartNumber = (value: number | string | null | undefined): number => {
const parsed = Math.round(Number(value));
if (value === null || value === '' || !Number.isFinite(parsed)) {
return DEFAULT_EXHIBIT_START_NUMBER;
}
return Math.min(MAX_EXHIBIT_START_NUMBER, Math.max(MIN_EXHIBIT_START_NUMBER, parsed));
};
/** Trims and caps the designation; an empty result means "not filled in yet". */
export const normalizeExhibitPrefix = (prefix: string): string =>
prefix.trim().slice(0, MAX_EXHIBIT_PREFIX_LENGTH);
/**
* Stamp text: designation plus number with exactly one space and no leading
* zeros — `Anlage K 6`, never `Anlage K 06`.
*/
export const buildExhibitLabel = (prefix: string, number: number): string =>
`${normalizeExhibitPrefix(prefix)} ${number}`;
/** File-name form of the designation: `Anlage K` → `Anlage_K`. */
export const buildExhibitPrefixSlug = (prefix: string): string =>
normalizeExhibitPrefix(prefix).replace(/\s+/g, '_').replace(UNSAFE_FILE_NAME_CHARS, '') ||
DEFAULT_EXHIBIT_PREFIX;
/**
* Zero-padding width: at least two digits, more when the highest assigned number
* needs them (1…99 → `06`, 1…100 → `006`).
*/
export const getExhibitNumberWidth = (highestNumber: number): number => {
const digits = Number.isFinite(highestNumber) ? String(Math.abs(highestNumber)).length : 1;
return Math.max(2, digits);
};
/**
* `{prefixSlug}_{paddedNumber}_{originalBase}.pdf` — the optional renaming
* described in the plan, e.g. `Anlage_K_06_Klageschrift.pdf`.
*/
export const buildExhibitFileName = (options: {
prefix: string;
number: number;
highestNumber: number;
originalName: string;
}): string => {
const width = getExhibitNumberWidth(options.highestNumber);
const paddedNumber = String(options.number).padStart(width, '0');
const originalBase = options.originalName.replace(/\.pdf$/i, '');
const originalSlug = originalBase.replace(UNSAFE_FILE_NAME_CHARS, '') || 'Dokument';
return `${buildExhibitPrefixSlug(options.prefix)}_${paddedNumber}_${originalSlug}.pdf`;
};
+26
View File
@@ -0,0 +1,26 @@
export type LatestToken = {
/** Starts a new operation and invalidates every earlier ticket. */
begin: () => number;
/** True while `ticket` is still the most recent `begin()`. */
isCurrent: (ticket: number) => boolean;
/** Invalidates the current ticket without starting a new operation. */
invalidate: () => void;
};
/**
* Guards async work so that only the most recent call may publish its result.
* Replaces the hand-rolled `generation`/`token` counters that every PDF route
* used to carry (stamp, pagination, page previews); late callers check
* `isCurrent` and clean up their own resources instead of writing stale state.
*/
export const createLatestToken = (): LatestToken => {
let current = 0;
return {
begin: () => (current += 1),
isCurrent: (ticket) => ticket === current,
invalidate: () => {
current += 1;
}
};
};
+66
View File
@@ -0,0 +1,66 @@
const ENCRYPTED_PDF_PATTERN = /password|encrypted|encryption|verschl/i;
/**
* Thrown by the overlay helpers for problems the user can fix (text too long,
* characters the standard font cannot encode). Callers render `message`
* directly instead of guessing from error text.
*
* Lives here rather than in `pdf-text-overlay.ts` so that tools which only need
* the error mapping do not pull pdf-lib into their bundle.
*/
export class TextOverlayError extends Error {
constructor(message: string) {
super(message);
this.name = 'TextOverlayError';
}
}
export type PdfErrorKind = 'encrypted' | 'text-overlay' | 'unknown';
/**
* pdf-lib (`EncryptedPDFError`) and pdfjs (`PasswordException`) both surface
* password protection through the error name/message. Tools use this to show a
* single German hint instead of a generic failure.
*/
export const isEncryptedPdfError = (error: unknown): boolean => {
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error);
return ENCRYPTED_PDF_PATTERN.test(message);
};
/**
* Classifies a PDF failure so routes can pick copy without matching on
* pdf-lib/pdfjs message text themselves.
*/
export const classifyPdfError = (error: unknown): PdfErrorKind => {
if (error instanceof TextOverlayError) return 'text-overlay';
if (isEncryptedPdfError(error)) return 'encrypted';
return 'unknown';
};
export const ENCRYPTED_PDF_MESSAGE =
'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.';
const GENERIC_LOAD_MESSAGE =
'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
/**
* User-facing message for a failed PDF operation. Encryption and overlay errors
* keep their specific German copy; everything else falls back to `fallback`.
*/
export const pdfErrorMessage = (error: unknown, fallback: string): string => {
switch (classifyPdfError(error)) {
case 'encrypted':
return ENCRYPTED_PDF_MESSAGE;
case 'text-overlay':
return error instanceof Error ? error.message : fallback;
default:
return fallback;
}
};
export const pdfLoadErrorMessage = (error: unknown, fallback: string = GENERIC_LOAD_MESSAGE) =>
pdfErrorMessage(error, fallback);
/** Shared row/banner message for PDFs that could not be transformed. */
export const pdfProcessingErrorMessage = (error: unknown, fallback: string) =>
pdfErrorMessage(error, fallback);
+38 -1
View File
@@ -11,13 +11,27 @@ export type OverlayPosition =
export type WatermarkPosition = OverlayPosition | 'diagonal';
/** Position choices shared by every tool that draws an overlay. */
export const OVERLAY_POSITION_OPTIONS: { value: OverlayPosition; label: string }[] = [
{ value: 'top-left', label: 'Oben links' },
{ value: 'top-center', label: 'Oben mittig' },
{ value: 'top-right', label: 'Oben rechts' },
{ value: 'center', label: 'Zentriert' },
{ value: 'bottom-left', label: 'Unten links' },
{ value: 'bottom-center', label: 'Unten mittig' },
{ value: 'bottom-right', label: 'Unten rechts' }
];
/** Distance between a mark and the page edge used by the drawing helpers. */
export const DEFAULT_OVERLAY_MARGIN = 20;
export const getPositionCoords = (
pageWidth: number,
pageHeight: number,
markWidth: number,
markHeight: number,
position: WatermarkPosition,
margin = 20
margin = DEFAULT_OVERLAY_MARGIN
): { x: number; y: number } => {
const horizontallyCentered = (pageWidth - markWidth) / 2;
@@ -50,3 +64,26 @@ export const parseHexColor = (hexColor: string): RGB => {
Number.parseInt(match[3], 16) / 255
);
};
/** Inset used by the HTML previews to approximate the PDF margin at reading size. */
export const PREVIEW_INSET = '5%';
const PREVIEW_POSITION_STYLES: Record<OverlayPosition, Record<string, string>> = {
center: { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' },
'top-left': { left: PREVIEW_INSET, top: PREVIEW_INSET },
'top-center': { left: '50%', top: PREVIEW_INSET, transform: 'translateX(-50%)' },
'top-right': { right: PREVIEW_INSET, top: PREVIEW_INSET },
'bottom-left': { bottom: PREVIEW_INSET, left: PREVIEW_INSET },
'bottom-center': { bottom: PREVIEW_INSET, left: '50%', transform: 'translateX(-50%)' },
'bottom-right': { bottom: PREVIEW_INSET, right: PREVIEW_INSET }
};
/** Declarative form, handy for `style:` directives or spreading into a style map. */
export const getPreviewPositionStyles = (position: OverlayPosition): Record<string, string> =>
PREVIEW_POSITION_STYLES[position];
/** Same styles as a single inline CSS string, for `style="…"` attributes. */
export const getPreviewPositionStyle = (position: OverlayPosition): string =>
Object.entries(getPreviewPositionStyles(position))
.map(([property, value]) => `${property}: ${value};`)
.join(' ');
+111
View File
@@ -0,0 +1,111 @@
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { parseHexColor, type OverlayPosition } from './pdf-overlay';
import { drawTextOverlay } from './pdf-text-overlay';
export const MIN_START_NUMBER = 1;
export const MAX_START_NUMBER = 99999;
export const PAGE_NUMBER_MARGIN = 28;
export const PAGE_NUMBER_COLOR = '#1a1a1a';
export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 18;
/** Longest allowed free text (Aktenzeichen) next to the sheet number. */
export const MAX_FREE_TEXT_LENGTH = 60;
const PAGINATION_PREFIXES = {
blatt: 'Bl. ',
seite: 'Seite ',
s: 'S. ',
nummer: ''
} as const;
export type PaginationFormat = keyof typeof PAGINATION_PREFIXES;
/**
* Select options. The label is the rendered first sheet number (`Bl. 1`), so the
* UI cannot drift from the PDF text — there is no template syntax involved.
*/
export const PAGINATION_FORMATS: { value: PaginationFormat; label: string }[] = (
Object.keys(PAGINATION_PREFIXES) as PaginationFormat[]
).map((value) => ({ value, label: `${PAGINATION_PREFIXES[value]}1`.trim() }));
export type PaginationTextScope = 'all' | 'first';
export type PaginationOptions = {
startNumber: number;
freeText: string;
textScope: PaginationTextScope;
format: PaginationFormat;
position: OverlayPosition;
fontSize: number;
};
type PaginationLabelOptions = Pick<
PaginationOptions,
'startNumber' | 'freeText' | 'textScope' | 'format'
>;
export const normalizeStartNumber = (value: number): number =>
Math.min(
MAX_START_NUMBER,
Math.max(MIN_START_NUMBER, Math.round(Number(value) || MIN_START_NUMBER))
);
/** Trims the free text and enforces `MAX_FREE_TEXT_LENGTH` for programmatic callers. */
export const normalizeFreeText = (value: string): string =>
value.trim().slice(0, MAX_FREE_TEXT_LENGTH);
/**
* Sheet label for one page: `123 Ja 60/26, Bl. 27`. The number always appears —
* it is not user-removable — while the free text only appears on the first page
* when that scope is selected.
*
* Pure formatter: `startNumber` must already be normalised (see `paginatePdf`).
*/
export const buildPaginationLabel = (
pageIndex: number,
options: PaginationLabelOptions
): string => {
const numberText = `${PAGINATION_PREFIXES[options.format]}${options.startNumber + pageIndex}`;
const freeText = options.freeText.trim();
const showsFreeText = options.textScope === 'all' || pageIndex === 0;
return showsFreeText && freeText ? `${freeText}, ${numberText}` : numberText;
};
/**
* Stamps every page with its ascending sheet number. Rotated scans are
* compensated so the number stays at the visual position.
*
* `MIN_FONT_SIZE` is the user's chosen floor: a label that does not fit even at
* that size raises a `TextOverlayError` instead of shrinking into illegibility.
*/
export const paginatePdf = async (
bytes: Uint8Array,
options: PaginationOptions
): Promise<Uint8Array> => {
const document = await PDFDocument.load(bytes);
const font = await document.embedFont(StandardFonts.Helvetica);
const color = parseHexColor(PAGE_NUMBER_COLOR);
// Normalise once at the boundary so the formatter stays pure.
const labelOptions: PaginationLabelOptions = {
startNumber: normalizeStartNumber(options.startNumber),
freeText: normalizeFreeText(options.freeText),
textScope: options.textScope,
format: options.format
};
for (const [pageIndex, page] of document.getPages().entries()) {
drawTextOverlay(page, {
font,
text: buildPaginationLabel(pageIndex, labelOptions),
size: options.fontSize,
color,
position: options.position,
margin: PAGE_NUMBER_MARGIN,
minSize: MIN_FONT_SIZE,
compensatePageRotation: true
});
}
return document.save();
};
+57
View File
@@ -0,0 +1,57 @@
let measureContext: CanvasRenderingContext2D | null | undefined;
const getMeasureContext = () => {
if (measureContext !== undefined) return measureContext;
if (typeof document === 'undefined') {
measureContext = null;
return measureContext;
}
measureContext = document.createElement('canvas').getContext('2d');
return measureContext;
};
/** Rough average advance width per character, used when no canvas is available. */
const FALLBACK_WIDTH_RATIO = 0.55;
const LINE_HEIGHT_RATIO = 1.2;
/**
* Approximate advance width of `text` in PDF points. The PDF standard font is
* Helvetica, and Arial/Liberation Sans are metrically compatible, so the
* browser's own text measurement is a good stand-in for the export.
*/
export const measureHelveticaText = (text: string, size: number): number => {
if (!text || size <= 0) return 0;
const context = getMeasureContext();
if (!context) return text.length * size * FALLBACK_WIDTH_RATIO;
context.font = `${size}px Helvetica, Arial, "Liberation Sans", sans-serif`;
return context.measureText(text).width;
};
/**
* Shrink factor (≤ 1) that the PDF export applies to keep a single line of text
* inside the available box. Lets the HTML preview mirror the export instead of
* clipping the label.
*/
export const getTextFitScale = (
text: string,
size: number,
availableWidth: number,
availableHeight: number
): number => {
if (!text || size <= 0 || availableWidth <= 0) return 1;
const width = measureHelveticaText(text, size);
const fitsWidth = width <= availableWidth;
const fitsHeight = size * LINE_HEIGHT_RATIO <= availableHeight;
if (fitsWidth && fitsHeight) return 1;
const widthScale = width > 0 ? availableWidth / width : 1;
const heightScale = availableHeight / (size * LINE_HEIGHT_RATIO);
return Math.max(0, Math.min(1, widthScale, heightScale));
};
+100
View File
@@ -0,0 +1,100 @@
import { pdfLoadErrorMessage } from './pdf-errors';
import { createLatestToken } from './latest-task';
export type PdfSessionOptions<THandle> = {
/** Builds the tool-specific handle (thumbnails, page renderer, …). */
load: (file: File, bytes: Uint8Array) => Promise<THandle>;
/** Releases a handle that is replaced or cleared. */
dispose?: (handle: THandle) => void;
/** Resets tool state when a new file is picked (selections, previews, …). */
onFileChange?: () => void;
/** Fallback message when loading fails for a reason other than encryption. */
loadFallbackMessage?: string;
};
/**
* Shared single-PDF lifecycle: pick a file, read its bytes once, build a handle
* and map load errors to German copy. Replaces the per-route copies of
* `file`/`bytes`/`handle`/`isLoading`/`loadingError` plus their generation
* counters.
*
* Reads must go through the getters (`session.file`) so Svelte tracks them.
*/
export const createPdfSession = <THandle>(options: PdfSessionOptions<THandle>) => {
let file = $state<File | null>(null);
let bytes = $state<Uint8Array | null>(null);
let handle = $state<THandle | null>(null);
let isLoading = $state(false);
let loadingError = $state<string | null>(null);
const loadToken = createLatestToken();
const release = () => {
if (handle === null) return;
options.dispose?.(handle);
handle = null;
};
const load = async (nextFile: File) => {
const ticket = loadToken.begin();
release();
options.onFileChange?.();
file = nextFile;
bytes = null;
loadingError = null;
isLoading = true;
try {
const nextBytes = new Uint8Array(await nextFile.arrayBuffer());
const nextHandle = await options.load(nextFile, nextBytes);
if (!loadToken.isCurrent(ticket)) {
options.dispose?.(nextHandle);
return;
}
bytes = nextBytes;
handle = nextHandle;
} catch (error) {
console.error('Failed to load PDF:', error);
if (loadToken.isCurrent(ticket)) {
file = null;
loadingError = pdfLoadErrorMessage(error, options.loadFallbackMessage);
}
} finally {
if (loadToken.isCurrent(ticket)) isLoading = false;
}
};
/** Drops the current file and handle (used on unmount). */
const clear = () => {
loadToken.invalidate();
release();
file = null;
bytes = null;
loadingError = null;
isLoading = false;
};
return {
get file() {
return file;
},
get bytes() {
return bytes;
},
get handle() {
return handle;
},
get isLoading() {
return isLoading;
},
get loadingError() {
return loadingError;
},
load,
clear
};
};
export type PdfSession<THandle> = ReturnType<typeof createPdfSession<THandle>>;
+377
View File
@@ -0,0 +1,377 @@
import { degrees, type PDFFont, type PDFImage, type PDFPage, type RGB } from 'pdf-lib';
import { TextOverlayError } from './pdf-errors';
import { getPositionCoords, type OverlayPosition } from './pdf-overlay';
/** Default distance between an overlay mark and the page edge, in points. */
export const TEXT_OVERLAY_MARGIN = 20;
/** Default inner padding of a framed overlay mark, in points. */
export const TEXT_OVERLAY_PADDING = 8;
/** Default font-size floor while shrinking an overlay to fit the page. */
export const MIN_TEXT_OVERLAY_SIZE = 4;
export const TEXT_OVERLAY_BORDER_WIDTH = 2;
/** Default height floor for image overlays, in points. */
export const MIN_IMAGE_OVERLAY_SIZE = 20;
/** Baseline nudge used by the framed text stamps so glyphs sit inside the border. */
const BASELINE_OFFSET_RATIO = 0.2;
const DEFAULT_TOO_LONG_MESSAGE =
'Der Text ist für mindestens eine Seite zu lang. Kürzen Sie den Text.';
const TEXT_NOT_ENCODABLE_MESSAGE =
'Der Text enthält Zeichen, die nicht dargestellt werden können. Entfernen Sie z. B. Emoji.';
export type TextOverlayOptions = {
font: PDFFont;
text: string;
/** Requested font size in points; shrunk down to `minSize` to fit the page. */
size: number;
color: RGB;
/** Where the mark sits on the *displayed* page (see `compensatePageRotation`). */
position: OverlayPosition;
/** Distance between the mark and the page edge in points. */
margin?: number;
/** Inner box padding; only used when `framed` is set. */
padding?: number;
framed?: boolean;
borderWidth?: number;
/** Additional counter-clockwise rotation of the mark in degrees. */
rotation?: number;
/** Font size floor while shrinking to fit the page. */
minSize?: number;
/**
* Place the mark on rotated scans (`/Rotate 90`, `180`, `270`) where it is
* visible instead of sideways. Opt-in so existing tools keep their output.
* When enabled, `position` refers to the displayed page; otherwise it refers
* to default user space.
*/
compensatePageRotation?: boolean;
tooLongMessage?: string;
};
export type ImageOverlayOptions = {
image: PDFImage;
/** Requested image height in points; scaled down (never up) to fit the page. */
size: number;
color: RGB;
position: OverlayPosition;
margin?: number;
/** Inner box padding; only used when `framed` is set. */
padding?: number;
framed?: boolean;
borderWidth?: number;
/** Additional counter-clockwise rotation of the mark in degrees. */
rotation?: number;
/** Height floor in points; smaller images are drawn at this height. */
minSize?: number;
};
type TextBox = {
size: number;
textHeight: number;
boxWidth: number;
boxHeight: number;
};
const measureText = (font: PDFFont, text: string, size: number) => {
try {
return { width: font.widthOfTextAtSize(text, size), height: font.heightAtSize(size) };
} catch (error) {
// The only expected failure here is a standard font that cannot encode a
// character. Anything else is a real bug and must not be reported to the
// user as an encoding problem.
if (error instanceof Error && /cannot encode|WinAnsi|StandardEncoding/i.test(error.message)) {
throw new TextOverlayError(TEXT_NOT_ENCODABLE_MESSAGE);
}
throw error;
}
};
type TextFitOptions = {
font: PDFFont;
text: string;
requestedSize: number;
/** Size of the displayed page after applying `/Rotate`. */
visualWidth: number;
visualHeight: number;
margin: number;
padding: number;
minSize: number;
tooLongMessage: string;
};
const fitTextToPage = ({
font,
text,
requestedSize,
visualWidth,
visualHeight,
margin,
padding,
minSize,
tooLongMessage
}: TextFitOptions): TextBox => {
const availableWidth = visualWidth - margin * 2;
const availableHeight = visualHeight - margin * 2;
let size = requestedSize;
let metrics = measureText(font, text, size);
while (
size > minSize &&
(metrics.width + padding * 2 > availableWidth || metrics.height + padding * 2 > availableHeight)
) {
size = Math.max(minSize, size - 0.5);
metrics = measureText(font, text, size);
}
if (
metrics.width + padding * 2 > availableWidth ||
metrics.height + padding * 2 > availableHeight
) {
throw new TextOverlayError(tooLongMessage);
}
return {
size,
textHeight: metrics.height,
boxWidth: metrics.width + padding * 2,
boxHeight: metrics.height + padding * 2
};
};
/**
* Lower-left corner for a (possibly rotated) box inside the given page size.
* The bounding box of the rotated mark is aligned to the requested position, so
* tilted text never crosses the page edge.
*/
export const getRotatedBoxPlacement = (
pageWidth: number,
pageHeight: number,
boxWidth: number,
boxHeight: number,
position: OverlayPosition,
angle: number,
margin = TEXT_OVERLAY_MARGIN
): { x: number; y: number } => {
const radians = (angle * Math.PI) / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const rotatedCorners = [
{ x: 0, y: 0 },
{ x: boxWidth * cosine, y: boxWidth * sine },
{ x: -boxHeight * sine, y: boxHeight * cosine },
{
x: boxWidth * cosine - boxHeight * sine,
y: boxWidth * sine + boxHeight * cosine
}
];
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
const boundingCoordinates = getPositionCoords(
pageWidth,
pageHeight,
maxX - minX,
maxY - minY,
position,
margin
);
return { x: boundingCoordinates.x - minX, y: boundingCoordinates.y - minY };
};
/** Normalised page rotation; anything that is not a multiple of 90 is ignored. */
const getPageRotation = (page: PDFPage): number => {
const angle = Math.round(page.getRotation().angle / 90) * 90;
return ((angle % 360) + 360) % 360;
};
/** Displayed page size after applying `/Rotate`. */
const getVisualPageSize = (pageWidth: number, pageHeight: number, pageRotation: number) =>
pageRotation % 180 === 0
? { width: pageWidth, height: pageHeight }
: { width: pageHeight, height: pageWidth };
/**
* Maps a point on the displayed page back into default user space. Viewers
* rotate page content clockwise by `/Rotate`, so this is the inverse rotation
* that keeps a mark at the requested visual position.
*
* Invariant (covered by `tests/e2e/unit/pdf-text-overlay.spec.ts`): for every
* rotation, `toUserSpace` composed with the clockwise viewer rotation is the
* identity, i.e. the mark appears at the requested corner on screen.
*/
const toUserSpace = (
point: { x: number; y: number },
pageWidth: number,
pageHeight: number,
pageRotation: number
) => {
switch (pageRotation) {
case 90:
return { x: pageWidth - point.y, y: point.x };
case 180:
return { x: pageWidth - point.x, y: pageHeight - point.y };
case 270:
return { x: point.y, y: pageHeight - point.x };
default:
return point;
}
};
/** Rotates an offset inside the mark box by `angle` around the box origin. */
const rotateOffset = (offsetX: number, offsetY: number, angle: number) => {
const radians = (angle * Math.PI) / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
return {
x: offsetX * cosine - offsetY * sine,
y: offsetX * sine + offsetY * cosine
};
};
/**
* Draws a single line of text (optionally framed) at one of the standard
* positions. Shrinks the font to fit the page and throws a `TextOverlayError`
* instead of clipping.
*/
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): void => {
const {
font,
text,
size,
color,
position,
margin = TEXT_OVERLAY_MARGIN,
padding = TEXT_OVERLAY_PADDING,
framed = false,
borderWidth = TEXT_OVERLAY_BORDER_WIDTH,
rotation = 0,
minSize = MIN_TEXT_OVERLAY_SIZE,
compensatePageRotation = false,
tooLongMessage = DEFAULT_TOO_LONG_MESSAGE
} = options;
const boxPadding = framed ? padding : 0;
const { width, height } = page.getSize();
const pageRotation = compensatePageRotation ? getPageRotation(page) : 0;
const visual = getVisualPageSize(width, height, pageRotation);
const box = fitTextToPage({
font,
text,
requestedSize: size,
visualWidth: visual.width,
visualHeight: visual.height,
margin,
padding: boxPadding,
minSize,
tooLongMessage
});
const placement = getRotatedBoxPlacement(
visual.width,
visual.height,
box.boxWidth,
box.boxHeight,
position,
rotation,
margin
);
const origin = toUserSpace(placement, width, height, pageRotation);
const angle = rotation + pageRotation;
const textOffset = rotateOffset(
boxPadding,
boxPadding + box.textHeight * BASELINE_OFFSET_RATIO,
angle
);
if (framed) {
page.drawRectangle({
x: origin.x,
y: origin.y,
width: box.boxWidth,
height: box.boxHeight,
borderColor: color,
borderWidth,
rotate: degrees(angle)
});
}
page.drawText(text, {
x: origin.x + textOffset.x,
y: origin.y + textOffset.y,
size: box.size,
font,
color,
rotate: degrees(angle)
});
};
/**
* Draws an image (optionally framed) at one of the standard positions, scaled
* down to fit the page. Kept next to `drawTextOverlay` so the rotated-placement
* maths lives in one place instead of being repeated in a route.
*/
export const drawImageOverlay = (page: PDFPage, options: ImageOverlayOptions): void => {
const {
image,
size,
color,
position,
margin = TEXT_OVERLAY_MARGIN,
padding = TEXT_OVERLAY_PADDING,
framed = false,
borderWidth = TEXT_OVERLAY_BORDER_WIDTH,
rotation = 0,
minSize = MIN_IMAGE_OVERLAY_SIZE
} = options;
const boxPadding = framed ? padding : 0;
const { width, height } = page.getSize();
const aspectRatio = image.width / image.height;
let drawHeight = size;
let drawWidth = drawHeight * aspectRatio;
const availableWidth = width - margin * 2 - boxPadding * 2;
const availableHeight = height - margin * 2 - boxPadding * 2;
if (drawWidth > availableWidth || drawHeight > availableHeight) {
const scale = Math.min(availableWidth / drawWidth, availableHeight / drawHeight);
drawWidth *= scale;
drawHeight *= scale;
}
if (drawHeight < minSize) {
drawHeight = minSize;
drawWidth = drawHeight * aspectRatio;
}
const placement = getRotatedBoxPlacement(
width,
height,
drawWidth + boxPadding * 2,
drawHeight + boxPadding * 2,
position,
rotation,
margin
);
if (framed) {
page.drawRectangle({
x: placement.x,
y: placement.y,
width: drawWidth + boxPadding * 2,
height: drawHeight + boxPadding * 2,
borderColor: color,
borderWidth,
rotate: degrees(rotation)
});
}
const imageOffset = rotateOffset(boxPadding, boxPadding, rotation);
page.drawImage(image, {
x: placement.x + imageOffset.x,
y: placement.y + imageOffset.y,
width: drawWidth,
height: drawHeight,
rotate: degrees(rotation)
});
};
+133 -24
View File
@@ -1,17 +1,48 @@
export type PdfThumbnail = {
import type { PDFPageProxy } from 'pdfjs-dist';
import { createLatestToken } from './latest-task';
/** One rasterised page, used for thumbnails and for on-demand previews. */
export type PdfPageImage = {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
};
/** Thumbnail grids and on-demand previews share one page-image shape. */
export type PdfThumbnail = PdfPageImage;
export type PdfDocumentHandle = {
pageCount: number;
thumbnails: PdfThumbnail[];
thumbnails: PdfPageImage[];
destroy: () => void;
};
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
/** Pixel scale used for on-demand page previews. */
export const PDF_PREVIEW_SCALE = 1.25;
/** Pixel scale used for the page grid thumbnails. */
export const PDF_THUMBNAIL_SCALE = 0.5;
/** Thrown when a render was superseded by a newer one or by unmounting. */
export class PreviewRenderCancelledError extends Error {
constructor(message = 'Die PDF-Vorschau wurde zwischenzeitlich geändert.') {
super(message);
this.name = 'PreviewRenderCancelledError';
}
}
/**
* Renders one page at a time. Used by tools that operate on a whole document
* (pagination) where rasterising every page up front would exhaust memory.
*/
export type PdfPageRenderer = {
pageCount: number;
render: (pageNumber: number) => Promise<PdfPageImage>;
destroy: () => void;
};
const loadPdfJs = async () => {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
@@ -19,36 +50,55 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
(module) => module.default
);
return getDocument;
};
const canvasToPngBlob = (canvas: HTMLCanvasElement) =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.'))),
'image/png'
);
});
/**
* Rasterises a single page and returns an object URL for the PNG. Shared by the
* thumbnail grid and the on-demand preview renderer so both stay in sync.
*/
const renderPageToPngUrl = async (page: PDFPageProxy, scale: number) => {
const viewport = page.getViewport({ 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 a canvas for the PDF page.');
await page.render({ canvas, canvasContext, viewport }).promise;
return {
imageUrl: URL.createObjectURL(await canvasToPngBlob(canvas)),
width: viewport.width,
height: viewport.height
};
};
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
const getDocument = await loadPdfJs();
// pdfjs transfers the buffer to its worker, so it always gets its own copy —
// callers keep using `data` for the export.
const loadingTask = getDocument({ data: new Uint8Array(data) });
const thumbnails: PdfThumbnail[] = [];
const thumbnails: PdfPageImage[] = [];
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
...(await renderPageToPngUrl(page, PDF_THUMBNAIL_SCALE))
});
}
@@ -69,3 +119,62 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
throw error;
}
}
export async function loadPdfPageRenderer(data: Uint8Array): Promise<PdfPageRenderer> {
const getDocument = await loadPdfJs();
const loadingTask = getDocument({ data: new Uint8Array(data) });
let pdfDocument;
try {
pdfDocument = await loadingTask.promise;
} catch (error) {
// Mirror `loadPdfDocument`: a failed load must not leave the worker and its
// copy of the document data alive.
await loadingTask.destroy();
throw error;
}
let destroyed = false;
let currentUrl: string | null = null;
const renderToken = createLatestToken();
const releaseUrl = () => {
if (currentUrl) URL.revokeObjectURL(currentUrl);
currentUrl = null;
};
return {
pageCount: pdfDocument.numPages,
render: async (pageNumber) => {
if (destroyed) throw new PreviewRenderCancelledError('Die PDF-Vorschau wurde geschlossen.');
const token = renderToken.begin();
const clampedPage = Math.min(
pdfDocument.numPages,
Math.max(1, Math.round(Number(pageNumber) || 1))
);
const page = await pdfDocument.getPage(clampedPage);
const rendered = await renderPageToPngUrl(page, PDF_PREVIEW_SCALE);
// A newer render (or unmount) superseded this one: drop the bitmap
// instead of leaving an orphaned object URL behind.
if (destroyed || !renderToken.isCurrent(token)) {
URL.revokeObjectURL(rendered.imageUrl);
throw new PreviewRenderCancelledError();
}
releaseUrl();
currentUrl = rendered.imageUrl;
return { pageNumber: clampedPage, ...rendered };
},
destroy: () => {
if (destroyed) return;
destroyed = true;
renderToken.invalidate();
releaseUrl();
void loadingTask.destroy();
}
};
}
+33 -7
View File
@@ -1,11 +1,37 @@
let pendingZipFiles: File[] = [];
/** Routes that can receive a handoff from another route. */
export type PendingFileTarget = 'bea' | 'pagination';
export const queueZipFiles = (files: File[]) => {
pendingZipFiles = [...pendingZipFiles, ...files];
type QueuedFile = { file: File; target: PendingFileTarget };
let queue: QueuedFile[] = [];
/**
* In-memory handoff between routes (start page → beA workspace, merge →
* pagination). Nothing is uploaded or persisted; the receiving route takes and
* clears the files queued for its own target on mount.
*
* The target keeps a queued ZIP from being consumed — and silently dropped — by
* a tool that only accepts PDFs.
*/
export const queuePendingFiles = (files: File[], target: PendingFileTarget) => {
queue = [...queue, ...files.map((file) => ({ file, target }))];
};
export const takeQueuedZipFiles = () => {
const files = pendingZipFiles;
pendingZipFiles = [];
return files;
/** Takes (and clears) the files queued for `target`; other targets stay queued. */
export const takePendingFiles = (target: PendingFileTarget): File[] => {
const taken: File[] = [];
const remaining: QueuedFile[] = [];
for (const entry of queue) {
if (entry.target === target) taken.push(entry.file);
else remaining.push(entry);
}
queue = remaining;
return taken;
};
/** Test/storybook helper: forget everything that is still queued. */
export const clearPendingFiles = () => {
queue = [];
};
+13
View File
@@ -12,6 +12,19 @@ export const tools: Tool[] = [
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
icon: 'stamp'
},
{
slug: 'pagination',
title: 'Paginieren',
description:
'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
icon: 'list-ordered'
},
{
slug: 'exhibits',
title: 'Anlagen nummerieren',
description: 'Nummerieren und benennen Sie Anlagen für die Einreichung — mit Anlagenstempel.',
icon: 'files'
},
{
slug: 'watermark',
title: 'Wasserzeichen',
+14 -10
View File
@@ -9,6 +9,8 @@
Scissors,
Merge,
Stamp,
ListOrdered,
Files,
RotateCw,
Info,
FileText
@@ -16,7 +18,7 @@
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 { queuePendingFiles } from '$lib/pending-files';
import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
import { tools } from '$lib/tools';
@@ -29,6 +31,8 @@
scissors: Scissors,
merge: Merge,
stamp: Stamp,
'list-ordered': ListOrdered,
files: Files,
'rotate-cw': RotateCw,
info: Info
};
@@ -37,7 +41,7 @@
const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
if (workspaceFiles.length === 0) return;
queueZipFiles(workspaceFiles);
queuePendingFiles(workspaceFiles, 'bea');
void goto('/tools/bea');
};
@@ -62,12 +66,12 @@
<div class="flex flex-col gap-10 px-4 py-10">
<!-- Hero -->
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-[28px] font-bold text-primary-900">Werkzeugkasten für beA</h1>
<p class="max-w-lg text-[15px] font-medium text-primary-700">
<h1 class="text-primary-900 text-[28px] font-bold">Werkzeugkasten für beA</h1>
<p class="text-primary-700 max-w-lg text-[15px] font-medium">
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
praktische PDF-Werkzeuge.
</p>
<p class="max-w-lg text-[13px] text-primary-700">
<p class="text-primary-700 max-w-lg text-[13px]">
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
</p>
</div>
@@ -84,7 +88,7 @@
icon={Archive}
class="py-10"
/>
<p class="mt-2 text-center text-[12px] text-primary-600">
<p class="text-primary-600 mt-2 text-center text-[12px]">
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs
können direkt in Teildokumente aufgeteilt werden.
</p>
@@ -92,12 +96,12 @@
<!-- Tool grid -->
<div class="mx-auto w-full max-w-4xl">
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Weitere PDF-Werkzeuge</h2>
<h2 class="text-primary-900 mb-4 text-[18px] font-bold">Weitere PDF-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.Root class="h-full cursor-pointer transition hover:shadow-md">
<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"
@@ -105,8 +109,8 @@
<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>
<h3 class="text-primary-900 text-[15px] font-bold">{tool.title}</h3>
<p class="text-primary-700 mt-1 text-[13px] leading-snug">{tool.description}</p>
</div>
</Card.Content>
</Card.Root>
+3 -3
View File
@@ -14,7 +14,7 @@
} from '$lib/zip-processing';
import { isAcceptedPdfFile } from '$lib/zip-selection';
import { downloadBlob } from '$lib/download';
import { takeQueuedZipFiles } from '$lib/pending-files';
import { takePendingFiles } from '$lib/pending-files';
import * as Card from '$lib/components/ui/card/index';
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
@@ -43,7 +43,7 @@
thumbnailWidthHydrated = true;
const queuedFiles = takeQueuedZipFiles();
const queuedFiles = takePendingFiles('bea');
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
});
@@ -201,6 +201,6 @@
{/snippet}
<div>
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
<h1 class="h1 text-primary-900 text-[20px] font-bold">beA-Edit</h1>
{@render content()}
</div>
+11 -7
View File
@@ -4,6 +4,7 @@
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import {
compressWithPreset,
compressionPresets,
@@ -12,6 +13,9 @@
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
import { AlertTriangle } from '@lucide/svelte';
const compressErrorMessage = (error: unknown) =>
pdfProcessingErrorMessage(error, 'Diese PDF konnte nicht komprimiert werden.');
let items = $state<BatchItem[]>([]);
let selectedPreset = $state(defaultCompressionPreset.id);
let isRunning = $state(false);
@@ -55,7 +59,7 @@
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht komprimiert werden.',
errorMessage: compressErrorMessage,
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
@@ -72,7 +76,7 @@
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht komprimiert werden.'
errorMessage: compressErrorMessage
});
} finally {
isRunning = false;
@@ -97,8 +101,8 @@
</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="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<div class="text-sm text-amber-800">
<p class="font-medium">Hinweis zur Qualität</p>
<p class="mt-1">
@@ -118,17 +122,17 @@
/>
{#if items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
<h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
<p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
<div class="space-y-2">
{#each compressionPresets as preset (preset.id)}
<label
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
class="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition {selectedPreset ===
preset.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary-300'}"
: 'hover:border-primary-300 border-border'}"
>
<input
type="radio"
+17 -19
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob, downloadBlobUrl } from '$lib/download';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
import { zipSync } from 'fflate';
import { Image } from '@lucide/svelte';
@@ -92,7 +94,7 @@
}
} catch (conversionError) {
console.error('Conversion failed:', conversionError);
error = 'Fehler beim Konvertieren des PDFs.';
error = pdfProcessingErrorMessage(conversionError, 'Fehler beim Konvertieren des PDFs.');
} finally {
isProcessing = false;
}
@@ -101,8 +103,8 @@
<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>
<h1 class="text-primary-900 text-2xl font-bold">Konvertieren</h1>
<p class="text-primary-700 mt-2 text-sm">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
</div>
{#if !pdfFile}
@@ -116,28 +118,28 @@
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">
<div class="border-primary-200 space-y-4 rounded-xl border bg-white p-4">
<div class="text-primary-900 flex items-center gap-2">
<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>
<p class="text-primary-900 mb-2 text-sm font-medium">Bildformat</p>
<div class="flex gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<label class="flex cursor-pointer items-center gap-2">
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
<span class="text-sm text-primary-700">PNG (verlustfrei)</span>
<span class="text-primary-700 text-sm">PNG (verlustfrei)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<label class="flex cursor-pointer items-center gap-2">
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
<span class="text-sm text-primary-700">JPEG (kleiner)</span>
<span class="text-primary-700 text-sm">JPEG (kleiner)</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-primary-900 mb-1" for="render-scale">
<label class="text-primary-900 mb-1 block text-sm font-medium" for="render-scale">
Auflösung: {scale}x
</label>
<input
@@ -149,7 +151,7 @@
bind:value={scale}
class="w-full"
/>
<p class="text-xs text-primary-600 mt-1">
<p class="text-primary-600 mt-1 text-xs">
{scale === 1
? '~72 DPI'
: scale === 2
@@ -162,11 +164,11 @@
{#if isProcessing && totalPages > 0}
<div class="space-y-1">
<div class="flex justify-between text-xs text-primary-700">
<div class="text-primary-700 flex justify-between text-xs">
<span>Wird konvertiert...</span>
<span>{progress} / {totalPages}</span>
</div>
<div class="h-2 w-full rounded-full bg-primary-100">
<div class="bg-primary-100 h-2 w-full rounded-full">
<div
class="h-2 rounded-full bg-primary transition-all"
style="width: {(progress / totalPages) * 100}%"
@@ -176,11 +178,7 @@
{/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}
<ToolError message={error} />
<div class="flex justify-end gap-2">
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
+7 -10
View File
@@ -2,6 +2,7 @@
import { PDFDocument } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
@@ -142,8 +143,8 @@
</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="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<div class="text-sm text-amber-800">
<p class="font-medium">Hinweis zur Qualität</p>
<p class="mt-1">
@@ -163,7 +164,7 @@
/>
{#if items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
<div class="flex items-center gap-2 text-foreground">
<Unlock class="h-5 w-5" />
<h2 class="text-lg font-semibold">Passwort</h2>
@@ -171,14 +172,14 @@
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
<div>
<label class="block text-sm font-medium text-foreground mb-1" for="decrypt-password">
<label class="mb-1 block text-sm font-medium text-foreground" for="decrypt-password">
Passwort
</label>
<input
id="decrypt-password"
type="password"
bind:value={password}
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
placeholder="Passwort der PDFs eingeben"
/>
</div>
@@ -199,11 +200,7 @@
/>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<ToolError message={error} />
{#if items.length > 0}
<div class="flex justify-end">
+7 -10
View File
@@ -2,6 +2,7 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
@@ -112,7 +113,7 @@
/>
{#if items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
<div class="flex items-center gap-2 text-foreground">
<Lock class="h-5 w-5" />
<h2 class="text-lg font-semibold">Passwort</h2>
@@ -120,21 +121,21 @@
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
<div>
<label class="block text-sm font-medium text-foreground mb-1" for="encrypt-password">
<label class="mb-1 block text-sm font-medium text-foreground" for="encrypt-password">
Passwort
</label>
<input
id="encrypt-password"
type="password"
bind:value={password}
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
placeholder="Passwort eingeben"
/>
</div>
<div>
<label
class="block text-sm font-medium text-foreground mb-1"
class="mb-1 block text-sm font-medium text-foreground"
for="encrypt-password-confirmation"
>
Passwort bestätigen
@@ -143,7 +144,7 @@
id="encrypt-password-confirmation"
type="password"
bind:value={confirmPassword}
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
placeholder="Passwort wiederholen"
/>
</div>
@@ -176,11 +177,7 @@
/>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<ToolError message={error} />
{#if items.length > 0}
<div class="flex justify-end">
+621
View File
@@ -0,0 +1,621 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { Files } from '@lucide/svelte';
import { PDFDocument, StandardFonts, type RGB } from 'pdf-lib';
import ExhibitFileList from '$lib/components/ExhibitFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import {
DEFAULT_EXHIBIT_PREFIX,
MAX_EXHIBIT_PREFIX_LENGTH,
MAX_EXHIBIT_START_NUMBER,
MIN_EXHIBIT_START_NUMBER,
buildExhibitFileName,
buildExhibitLabel,
normalizeExhibitPrefix,
normalizeExhibitStartNumber
} from '$lib/exhibit-naming';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import {
OVERLAY_POSITION_OPTIONS,
getPreviewPositionStyle,
parseHexColor,
type OverlayPosition
} from '$lib/pdf-overlay';
import { createPdfSession } from '$lib/pdf-session.svelte';
import {
MIN_TEXT_OVERLAY_SIZE,
TEXT_OVERLAY_MARGIN,
drawTextOverlay
} from '$lib/pdf-text-overlay';
import { getTextFitScale } from '$lib/pdf-preview-text';
import {
PDF_PREVIEW_SCALE,
PreviewRenderCancelledError,
loadPdfPageRenderer,
type PdfPageImage,
type PdfPageRenderer
} from '$lib/pdf-thumbnails';
const PRESETS = ['Anlage', 'Anlage K', 'Anlage B', 'Anlage XYZ'] as const;
/** Inner padding of the framed exhibit stamp, in points. */
const EXHIBIT_PADDING = 6;
const EXHIBIT_TOO_LONG_MESSAGE =
'Die Anlagen-Bezeichnung ist für mindestens eine Seite zu lang. Kürzen Sie die Bezeichnung.';
/** Number and label frozen when a row is created, so results stay valid. */
type Assignment = { number: number; label: string };
type RunOptions = {
prefix: string;
fontSize: number;
color: RGB;
position: OverlayPosition;
everyPage: boolean;
rename: boolean;
highestNumber: number;
};
let items = $state<BatchItem[]>([]);
let prefix = $state(DEFAULT_EXHIBIT_PREFIX);
let startNumberInput = $state<number | undefined>(1);
let everyPage = $state(false);
let position = $state<OverlayPosition>('top-right');
let fontSize = $state(14);
let stampColor = $state('#1a1a1a');
let rename = $state(true);
let assignments = $state(new Map<string, Assignment>());
let selectedId = $state<string | null>(null);
let isRunning = $state(false);
let error = $state<string | null>(null);
let preview = $state<PdfPageImage | null>(null);
let previewError = $state<string | null>(null);
let previewRetry = $state(0);
const session = createPdfSession<PdfPageRenderer>({
load: (_file, bytes) => loadPdfPageRenderer(bytes),
dispose: (pageRenderer) => pageRenderer.destroy(),
onFileChange: () => {
preview = null;
previewError = null;
}
});
const startNumber = $derived(normalizeExhibitStartNumber(startNumberInput));
const effectivePrefix = $derived(normalizeExhibitPrefix(prefix));
const prefixMissing = $derived(effectivePrefix.length === 0);
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
const canRun = $derived(!isRunning && pendingCount > 0 && !prefixMissing);
const hasResults = $derived(items.some((item) => item.status === 'done'));
const numberFor = (item: BatchItem): number => {
const frozen = assignments.get(item.id);
if (frozen) return frozen.number;
const index = items.findIndex((entry) => entry.id === item.id);
return startNumber + Math.max(0, index);
};
const labelFor = (item: BatchItem): string =>
assignments.get(item.id)?.label ?? buildExhibitLabel(effectivePrefix, numberFor(item));
const highestNumber = $derived(
items.reduce(
(highest, item) => Math.max(highest, numberFor(item)),
startNumber + Math.max(0, items.length - 1)
)
);
/** Freezes number and label once, so reordering never rewrites a result. */
/** Freezes number and label once, so reordering never rewrites a result. */
const freezeAssignment = (item: BatchItem): Assignment => {
const existing = assignments.get(item.id);
if (existing) return existing;
const number = numberFor(item);
const assignment = { number, label: buildExhibitLabel(effectivePrefix, number) };
assignments = new Map(assignments).set(item.id, assignment);
return assignment;
};
const assignmentFor = (item: BatchItem): Assignment =>
assignments.get(item.id) ?? freezeAssignment(item);
const labels = $derived(
Object.fromEntries(items.map((item) => [item.id, labelFor(item)])) as Record<string, string>
);
const fileNames = $derived(
Object.fromEntries(
items.map((item) => [
item.id,
rename
? buildExhibitFileName({
prefix: effectivePrefix,
number: numberFor(item),
highestNumber,
originalName: item.file.name
})
: null
])
) as Record<string, string | null>
);
const numberExample = $derived(
Array.from({ length: Math.min(3, items.length) }, (_, index) => startNumber + index).join(', ')
);
const prefixExample = $derived(
buildExhibitLabel(effectivePrefix || DEFAULT_EXHIBIT_PREFIX, startNumber)
);
const renameExample = $derived(
buildExhibitFileName({
prefix: effectivePrefix || DEFAULT_EXHIBIT_PREFIX,
number: startNumber,
highestNumber,
originalName: 'Klageschrift.pdf'
})
);
const selectedLabel = $derived(selectedId ? (labels[selectedId] ?? '') : '');
const selectedPageCount = $derived(session.handle?.pageCount ?? 0);
const previewFit = $derived.by(() => {
if (!preview) return { scale: 1, shrunk: false, tooLong: false };
const pageWidth = preview.width / PDF_PREVIEW_SCALE;
const pageHeight = preview.height / PDF_PREVIEW_SCALE;
const raw = getTextFitScale(
selectedLabel,
fontSize,
pageWidth - TEXT_OVERLAY_MARGIN * 2 - EXHIBIT_PADDING * 2,
pageHeight - TEXT_OVERLAY_MARGIN * 2 - EXHIBIT_PADDING * 2
);
const minScale = MIN_TEXT_OVERLAY_SIZE / fontSize;
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
});
/** Font size in container-query units so the preview scales like the export. */
const previewFontSize = $derived(
preview ? ((fontSize * previewFit.scale) / (preview.width / PDF_PREVIEW_SCALE)) * 100 : 0
);
const handleFilesSelected = (files: File[]) => {
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
const additions = files
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
.map((file) => createBatchItem(file));
if (additions.length === 0) return;
items = [...items, ...additions];
error = null;
};
const moveItem = (from: number, to: number) => {
if (isRunning) return;
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) return;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
items = next;
};
const selectItem = (id: string) => {
if (isRunning) return;
if (selectedId === id) {
selectedId = null;
session.clear();
return;
}
const item = items.find((entry) => entry.id === id);
if (!item) return;
selectedId = id;
void session.load(item.file);
};
const currentRunOptions = (): RunOptions => ({
prefix: effectivePrefix,
fontSize: Number(fontSize),
color: parseHexColor(stampColor),
position,
everyPage,
rename,
highestNumber
});
const processFile = async (file: File, item: BatchItem, options: RunOptions) => {
const assignment = assignmentFor(item);
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const pages = pdfDoc.getPages();
const targets = options.everyPage ? pages : pages.slice(0, 1);
for (const page of targets) {
drawTextOverlay(page, {
font,
text: assignment.label,
size: options.fontSize,
color: options.color,
position: options.position,
margin: TEXT_OVERLAY_MARGIN,
padding: EXHIBIT_PADDING,
framed: true,
minSize: MIN_TEXT_OVERLAY_SIZE,
compensatePageRotation: true,
tooLongMessage: EXHIBIT_TOO_LONG_MESSAGE
});
}
const bytes = await pdfDoc.save();
const name = options.rename
? buildExhibitFileName({
prefix: options.prefix,
number: assignment.number,
highestNumber: options.highestNumber,
originalName: file.name
})
: file.name;
return { bytes, name };
};
const exhibitErrorMessage = (processingError: unknown) =>
pdfProcessingErrorMessage(
processingError,
'Diese PDF konnte nicht als Anlage erstellt werden. Prüfen Sie die Datei.'
);
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
const startBatch = async () => {
if (isRunning || !canRun) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
// Freeze every number before the first file is processed: reordering or
// changing the start number afterwards must not rewrite a result.
for (const item of queue) freezeAssignment(item);
const options = currentRunOptions();
isRunning = true;
try {
await runBatch(queue, (file, item) => processFile(file, item, options), updateItem, {
errorMessage: exhibitErrorMessage,
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally {
isRunning = false;
}
};
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
const options = currentRunOptions();
isRunning = true;
try {
await runBatch([item], (file, entry) => processFile(file, entry, options), updateItem, {
errorMessage: exhibitErrorMessage
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
items = items.filter((item) => item.id !== id);
if (selectedId === id) {
selectedId = null;
session.clear();
}
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
};
$effect(() => {
const activeRenderer = session.handle;
void previewRetry;
if (!activeRenderer) return;
let cancelled = false;
void activeRenderer
.render(1)
.then((result) => {
if (cancelled) return;
preview = result;
previewError = null;
})
.catch((renderError) => {
if (cancelled || renderError instanceof PreviewRenderCancelledError) return;
console.error('Failed to render exhibit preview:', renderError);
preview = null;
previewError = 'Die Vorschau dieser Anlage konnte nicht erzeugt werden.';
});
return () => {
cancelled = true;
};
});
onDestroy(() => {
session.clear();
});
</script>
<svelte:head>
<title>Anlagen nummerieren — beA-Edit</title>
</svelte:head>
<div class="mx-auto flex max-w-5xl flex-col gap-6 pb-10">
<div>
<div class="mb-2 flex items-center gap-2 text-secondary">
<Files class="size-5" aria-hidden="true" />
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Anlagenbezeichnung</span>
</div>
<h1 class="text-2xl font-bold text-foreground">Anlagen nummerieren</h1>
<p class="mt-2 max-w-2xl text-sm text-primary">
Versehen Sie mehrere PDFs mit fortlaufenden Anlagen-Bezeichnungen und benennen Sie die Dateien
passend um.
</p>
</div>
<FileDropzone
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'PDFs als Anlagen hinzufügen' : 'Weitere PDFs hinzufügen'}
sublabel={items.length === 0
? 'PDFs hier ablegen oder auswählen — eine Datei entspricht einer Anlage'
: 'Weitere Anlagen hinzufügen – bestehende Dateien bleiben erhalten'}
/>
{#if items.length > 0}
<div class="grid gap-6 {selectedId ? 'lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]' : ''}">
<section
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
aria-labelledby="exhibit-options-title"
>
<div>
<h2 id="exhibit-options-title" class="text-lg font-semibold text-foreground">
Anlagen einstellen
</h2>
<p class="mt-1 text-xs text-muted-foreground">
Eine Datei entspricht einer Anlage. Die Einstellungen gelten für alle Dateien der Liste.
</p>
</div>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">Bezeichnung</legend>
<div class="flex flex-wrap gap-2">
{#each PRESETS as preset}
<button
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-bold tracking-wide transition focus:outline-none focus-visible:ring-2 focus-visible:ring-ring {normalizeExhibitPrefix(
prefix
) === preset
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-foreground hover:border-primary/40'}"
aria-pressed={normalizeExhibitPrefix(prefix) === preset}
onclick={() => (prefix = preset)}
>
{preset}
</button>
{/each}
</div>
</fieldset>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="exhibit-prefix">
Bezeichnung (frei wählbar)
</label>
<input
id="exhibit-prefix"
type="text"
bind:value={prefix}
maxlength={MAX_EXHIBIT_PREFIX_LENGTH}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
/>
{#if prefixMissing}
<p class="text-xs text-destructive" role="status">
Bitte geben Sie eine Bezeichnung an, z. B. „Anlage K“.
</p>
{:else}
<p class="text-xs text-muted-foreground" role="status">
Beispiel: <span class="font-semibold">{prefixExample}</span>
</p>
{/if}
</div>
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label class="text-sm font-medium text-foreground" for="exhibit-start-number">
Startnummer
</label>
<input
id="exhibit-start-number"
type="number"
inputmode="numeric"
step="1"
min={MIN_EXHIBIT_START_NUMBER}
max={MAX_EXHIBIT_START_NUMBER}
bind:value={startNumberInput}
onchange={() => (startNumberInput = startNumber)}
onblur={() => (startNumberInput = startNumber)}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm sm:w-28"
/>
{#if items.length > 0}
<p class="text-xs text-muted-foreground sm:col-span-2" role="note">
{items.length} Datei{items.length === 1 ? '' : 'en'} → {effectivePrefix ||
DEFAULT_EXHIBIT_PREFIX}
{numberExample}{items.length > 3 ? ', …' : ''}
</p>
{/if}
</div>
<label class="flex items-center gap-2 text-sm text-foreground" for="exhibit-every-page">
<input id="exhibit-every-page" type="checkbox" bind:checked={everyPage} />
Auf jeder Seite stempeln
</label>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="exhibit-position">
Position
</label>
<select
id="exhibit-position"
bind:value={position}
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
>
{#each OVERLAY_POSITION_OPTIONS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="exhibit-color">Farbe</label>
<input
id="exhibit-color"
type="color"
bind:value={stampColor}
class="h-10 w-full cursor-pointer rounded-lg border border-input bg-background p-1"
/>
</div>
</div>
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label class="text-sm font-medium text-foreground" for="exhibit-font-size">
Schriftgröße: {fontSize} pt
</label>
<input
id="exhibit-font-size"
type="range"
min="10"
max="24"
bind:value={fontSize}
aria-valuetext="{fontSize} Punkt"
class="sm:col-span-2"
/>
</div>
<div class="flex flex-col gap-2 border-t border-border pt-4">
<label class="flex items-center gap-2 text-sm text-foreground" for="exhibit-rename">
<input id="exhibit-rename" type="checkbox" bind:checked={rename} />
Dateien umbenennen
</label>
{#if rename}
<p class="text-xs text-muted-foreground" role="note">
Muster: <code class="rounded bg-muted px-1 py-0.5">{renameExample}</code>
</p>
{/if}
</div>
{#if isRunning || hasResults}
<p class="text-xs text-primary" role="note">
Bereits erstellte Anlagen behalten ihre Nummer.
</p>
{/if}
</section>
{#if selectedId}
<section
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
aria-labelledby="exhibit-preview-title"
>
<div class="flex items-start justify-between gap-3">
<div>
<h2 id="exhibit-preview-title" class="text-lg font-semibold text-foreground">
Vorschau
</h2>
<p class="mt-1 text-xs text-muted-foreground">
{selectedLabel} · Seite 1 von {selectedPageCount}
</p>
</div>
</div>
{#if previewError}
<div
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
role="alert"
>
<p class="text-sm text-foreground">{previewError}</p>
<Button variant="tertiary" onclick={() => (previewRetry += 1)}>
Erneut versuchen
</Button>
</div>
{:else if preview}
<div
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
>
<img src={preview.imageUrl} alt="Vorschau von Seite 1" class="block h-full w-full" />
<div class="absolute" style={getPreviewPositionStyle(position)}>
<div
class="border-2 font-sans leading-tight font-bold whitespace-nowrap"
style="border-color: {stampColor}; color: {stampColor}; padding: 0.35em; font-size: {previewFontSize}cqw;"
data-testid="exhibit-preview"
data-position={position}
>
{selectedLabel}
</div>
</div>
</div>
{:else}
<div
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
>
Vorschau wird geladen …
</div>
{/if}
{#if previewFit.tooLong}
<p class="text-xs text-destructive" role="status">
Die Bezeichnung ist für diese Seite zu lang. Kürzen Sie sie.
</p>
{:else if previewFit.shrunk}
<p class="text-xs text-muted-foreground" role="status">
Auf dieser Seite wird die Bezeichnung verkleinert.
</p>
{/if}
</section>
{/if}
</div>
<ExhibitFileList
{items}
{labels}
{fileNames}
{selectedId}
zipBaseName="anlagen"
disabled={isRunning}
onSelect={selectItem}
onMove={moveItem}
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
<div class="flex flex-col items-end gap-3">
<ToolError message={error} class="w-full" />
<Button size="large" onclick={startBatch} disabled={!canRun}>
{isRunning ? 'Anlagen werden erstellt …' : 'Anlagen erstellen'}
</Button>
</div>
{:else}
<ToolError message={error} />
{/if}
</div>
+47 -21
View File
@@ -1,14 +1,22 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { goto } from '$app/navigation';
import { downloadBlobFile, pdfBytesToBlob } from '$lib/download';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { queuePendingFiles } from '$lib/pending-files';
import { mergePdfFiles } from '$lib/pdf-processing';
import { Trash2 } from '@lucide/svelte';
type MergeAction = 'download' | 'paginate';
let pdfFiles: File[] = $state([]);
let isProcessing = $state(false);
let activeAction = $state<MergeAction | null>(null);
let error = $state<string | null>(null);
const isProcessing = $derived(activeAction !== null);
const handleFilesSelected = (files: File[]) => {
pdfFiles = [...pdfFiles, ...files];
error = null;
@@ -26,27 +34,37 @@
pdfFiles = newFiles;
};
const mergePdfs = async () => {
if (pdfFiles.length < 2) return;
const runMerge = async (action: MergeAction) => {
if (pdfFiles.length < 2 || activeAction) return;
isProcessing = true;
activeAction = action;
error = null;
try {
downloadBlob(await mergePdfFiles(pdfFiles), 'zusammengefuegt.pdf');
const merged = pdfBytesToBlob(await mergePdfFiles(pdfFiles));
if (action === 'paginate') {
queuePendingFiles(
[new File([merged], 'zusammengefuegt.pdf', { type: 'application/pdf' })],
'pagination'
);
await goto('/tools/pagination');
} else {
downloadBlobFile(merged, 'zusammengefuegt.pdf');
}
} catch (err) {
console.error('Merge failed:', err);
error = 'Fehler beim Zusammenfügen der PDFs.';
error = pdfProcessingErrorMessage(err, 'Fehler beim Zusammenfügen der PDFs.');
} finally {
isProcessing = false;
activeAction = null;
}
};
</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">
<h1 class="text-primary-900 text-2xl font-bold">PDFs zusammenfügen</h1>
<p class="text-primary-700 mt-2 text-sm">
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
</p>
</div>
@@ -55,30 +73,38 @@
{#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">
<div class="flex items-center justify-between gap-3">
<h2 class="text-primary-900 text-lg font-semibold">
{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'}
<div class="flex flex-wrap items-center justify-end gap-2">
<Button
variant="tertiary"
onclick={() => runMerge('paginate')}
disabled={isProcessing || pdfFiles.length < 2}
>
{activeAction === 'paginate' ? 'Wird zusammengefügt …' : 'Zusammenfügen & paginieren'}
</Button>
<Button
onclick={() => runMerge('download')}
disabled={isProcessing || pdfFiles.length < 2}
>
{activeAction === 'download' ? '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}
<ToolError message={error} />
<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">
<div class="border-primary-200 flex items-center gap-3 rounded-lg border 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>
<span class="text-primary-900 flex-1 truncate text-sm">{file.name}</span>
<div class="flex gap-1">
<Button
variant="tertiary"
+20 -14
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { formatBytes } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { readPdfMetadata, stripPdfMetadata, type PdfMetadataResult } from '$lib/pdf-metadata';
import { AlertTriangle, Info, Lock } from '@lucide/svelte';
@@ -25,9 +27,12 @@
try {
const bytes = new Uint8Array(await selected.arrayBuffer());
result = await readPdfMetadata(bytes);
} catch {
} catch (loadError) {
file = null;
error = 'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.';
error = pdfLoadErrorMessage(
loadError,
'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.'
);
} finally {
isReading = false;
}
@@ -41,8 +46,11 @@
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.';
} catch (cleanError) {
error = pdfProcessingErrorMessage(
cleanError,
'Die Metadaten konnten nicht entfernt werden. Bitte versuchen Sie es erneut.'
);
} finally {
isCleaning = false;
}
@@ -88,13 +96,11 @@
{#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>
<ToolError message={error} />
{: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="flex gap-3 rounded-lg border border-red-200 bg-red-50 p-4" role="alert">
<Lock class="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
<div class="text-sm text-red-800">
<p class="font-medium">Dieses PDF ist verschlüsselt</p>
<p class="mt-1">
@@ -105,25 +111,25 @@
</div>
</div>
{:else}
<div class="rounded-2xl border border-border bg-card p-6 space-y-5" data-metadata-card>
<div class="space-y-5 rounded-2xl border border-border bg-card p-6" 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>
<p class="truncate text-xs text-primary" 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}>
<dd class="text-sm wrap-break-word text-primary" 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" />
<div class="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<p class="text-sm text-amber-800">
Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen
nicht entfernt.
+354
View File
@@ -0,0 +1,354 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { ListOrdered } from '@lucide/svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PaginationPreview from '$lib/components/PaginationPreview.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { OVERLAY_POSITION_OPTIONS, type OverlayPosition } from '$lib/pdf-overlay';
import {
MAX_FONT_SIZE,
MAX_FREE_TEXT_LENGTH,
MAX_START_NUMBER,
MIN_FONT_SIZE,
MIN_START_NUMBER,
PAGINATION_FORMATS,
buildPaginationLabel,
normalizeFreeText,
normalizeStartNumber,
paginatePdf,
type PaginationFormat,
type PaginationTextScope
} from '$lib/pdf-pagination';
import { createPdfSession } from '$lib/pdf-session.svelte';
import {
PreviewRenderCancelledError,
loadPdfPageRenderer,
type PdfPageImage,
type PdfPageRenderer
} from '$lib/pdf-thumbnails';
import { takePendingFiles } from '$lib/pending-files';
const session = createPdfSession<PdfPageRenderer>({
load: (_file, bytes) => loadPdfPageRenderer(bytes),
dispose: (pageRenderer) => pageRenderer.destroy(),
onFileChange: () => {
currentPage = 1;
preview = null;
previewError = null;
}
});
const file = $derived(session.file);
const sourceBytes = $derived(session.bytes);
const renderer = $derived(session.handle);
const pageCount = $derived(renderer?.pageCount ?? 0);
let preview = $state<PdfPageImage | null>(null);
let previewError = $state<string | null>(null);
let previewRetry = $state(0);
let currentPage = $state(1);
let isProcessing = $state(false);
let error = $state<string | null>(null);
let startNumberInput = $state<number | undefined>(1);
let freeText = $state('');
let textScope = $state<PaginationTextScope>('all');
let numberFormat = $state<PaginationFormat>('blatt');
let position = $state<OverlayPosition>('bottom-center');
let fontSize = $state(11);
const startNumber = $derived(normalizeStartNumber(startNumberInput ?? MIN_START_NUMBER));
const labelOptions = $derived({
startNumber,
freeText: normalizeFreeText(freeText),
textScope,
format: numberFormat
});
const previewLabel = $derived(buildPaginationLabel(currentPage - 1, labelOptions));
const exampleLabel = $derived(buildPaginationLabel(0, labelOptions));
const errorMessage = $derived(error ?? session.loadingError);
const canPaginate = $derived(sourceBytes !== null && !isProcessing && !session.isLoading);
const freeTextHint = $derived(
textScope === 'all'
? 'Der Zusatztext erscheint auf jeder Seite.'
: 'Der Zusatztext erscheint nur auf der ersten Seite.'
);
const freeTextCount = $derived(`${normalizeFreeText(freeText).length}/${MAX_FREE_TEXT_LENGTH}`);
const showPage = (pageNumber: number) => {
if (!renderer) return;
currentPage = Math.min(Math.max(1, Math.round(pageNumber || 1)), renderer.pageCount);
};
/** Clamps typed input on commit so the field can be cleared while editing. */
const commitStartNumber = () => {
startNumberInput = startNumber;
};
const handleFilesSelected = (files: File[]) => {
const [nextFile] = files;
if (nextFile) void session.load(nextFile);
};
const paginate = async () => {
const bytes = sourceBytes;
const currentFile = file;
if (!bytes || !currentFile) return;
isProcessing = true;
error = null;
try {
const output = await paginatePdf(bytes, {
startNumber,
freeText,
textScope,
format: numberFormat,
position,
fontSize
});
downloadBlob(output, `${currentFile.name.replace(/\.pdf$/i, '')}_paginiert.pdf`);
} catch (processingError) {
console.error('Pagination failed:', processingError);
error = pdfProcessingErrorMessage(
processingError,
'Das PDF konnte nicht paginiert werden. Prüfen Sie die Datei.'
);
} finally {
isProcessing = false;
}
};
$effect(() => {
const activeRenderer = renderer;
const pageNumber = currentPage;
void previewRetry;
if (!activeRenderer) return;
let cancelled = false;
void activeRenderer
.render(pageNumber)
.then((result) => {
if (cancelled) return;
preview = result;
previewError = null;
})
.catch((renderError) => {
if (cancelled || renderError instanceof PreviewRenderCancelledError) return;
console.error('Failed to render pagination preview:', renderError);
preview = null;
previewError = 'Die Vorschau dieser Seite konnte nicht erzeugt werden.';
});
return () => {
cancelled = true;
};
});
onMount(() => {
const [pending] = takePendingFiles('pagination');
if (pending) void session.load(pending);
});
onDestroy(() => {
session.clear();
});
</script>
<svelte:head>
<title>PDF paginieren — beA-Edit</title>
</svelte:head>
<div class="mx-auto flex max-w-5xl flex-col gap-6 pb-10">
<div>
<div class="mb-2 flex items-center gap-2 text-secondary">
<ListOrdered class="size-5" aria-hidden="true" />
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Seitenzählung</span>
</div>
<h1 class="text-2xl font-bold text-foreground">Paginieren</h1>
<p class="mt-2 max-w-2xl text-sm text-primary">
Versehen Sie alle Seiten mit fortlaufenden Blattnummern — auf Wunsch kombiniert mit einem
Aktenzeichen.
</p>
</div>
<FileDropzone
onFilesSelected={handleFilesSelected}
label={file ? 'Andere PDF wählen' : 'PDF zum Paginieren hinzufügen'}
sublabel={session.isLoading
? 'PDF wird geladen …'
: file
? `${file.name}${pageCount > 0 ? ` · ${pageCount} Seite${pageCount === 1 ? '' : 'n'}` : ''}`
: 'PDF hier ablegen oder auswählen'}
/>
{#if file}
<RasterizationWarning {file} />
{/if}
{#if file && renderer}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
<section
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
aria-labelledby="pagination-options-title"
>
<div>
<h2 id="pagination-options-title" class="text-lg font-semibold text-foreground">
Nummerierung einstellen
</h2>
<p class="mt-1 text-xs text-muted-foreground">
Die Nummern laufen über das gesamte Dokument und werden für schmale Seiten automatisch
verkleinert (bis {MIN_FONT_SIZE} pt).
</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="pagination-start">
Startnummer
</label>
<input
id="pagination-start"
type="number"
inputmode="numeric"
step="1"
min={MIN_START_NUMBER}
max={MAX_START_NUMBER}
bind:value={startNumberInput}
onchange={commitStartNumber}
onblur={commitStartNumber}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
/>
<p class="text-xs text-muted-foreground">
Seite 1 erhält diese Nummer, danach zählt das Dokument fortlaufend weiter.
</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="pagination-text">
Zusatztext (optional)
</label>
<input
id="pagination-text"
type="text"
bind:value={freeText}
maxlength={MAX_FREE_TEXT_LENGTH}
placeholder="z. B. 123 Ja 60/26"
aria-describedby="pagination-text-help"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
/>
<p id="pagination-text-help" class="text-xs text-muted-foreground">
Höchstens {MAX_FREE_TEXT_LENGTH} Zeichen ({freeTextCount}), zum Beispiel ein
Aktenzeichen.
</p>
</div>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">Zusatztext anzeigen</legend>
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="pagination-text-scope"
value="all"
checked={textScope === 'all'}
onchange={() => (textScope = 'all')}
/>
Auf jeder Seite
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="pagination-text-scope"
value="first"
checked={textScope === 'first'}
onchange={() => (textScope = 'first')}
/>
Nur auf der ersten Seite
</label>
<p class="text-xs text-muted-foreground">{freeTextHint}</p>
</fieldset>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="pagination-format">
Zahlenformat
</label>
<select
id="pagination-format"
bind:value={numberFormat}
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
>
{#each PAGINATION_FORMATS as format (format.value)}
<option value={format.value}>{format.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="pagination-position">
Position
</label>
<select
id="pagination-position"
bind:value={position}
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
>
{#each OVERLAY_POSITION_OPTIONS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label class="text-sm font-medium text-foreground" for="pagination-font-size">
Schriftgröße: {fontSize} pt
</label>
<input
id="pagination-font-size"
type="range"
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
bind:value={fontSize}
aria-valuetext="{fontSize} Punkt"
class="sm:col-span-2"
/>
<p class="text-xs text-muted-foreground sm:col-span-2">
Wunschgröße; reicht der Platz nicht, wird der Text bis {MIN_FONT_SIZE} pt verkleinert.
</p>
</div>
<p
class="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-foreground"
data-testid="pagination-example"
role="status"
aria-live="polite"
>
Beispiel: {exampleLabel}
</p>
</section>
<PaginationPreview
{preview}
error={previewError}
label={previewLabel}
{fontSize}
{position}
{currentPage}
{pageCount}
onRetry={() => (previewRetry += 1)}
onShowPage={showPage}
/>
</div>
{/if}
<div class="flex flex-col items-end gap-3">
<ToolError message={errorMessage} class="w-full" />
{#if renderer}
<Button size="large" onclick={paginate} disabled={!canPaginate}>
{isProcessing ? 'Wird paginiert …' : 'Paginieren & herunterladen'}
</Button>
{/if}
</div>
</div>
+47 -88
View File
@@ -2,52 +2,36 @@
import { onDestroy } from 'svelte';
import { degrees, PDFDocument } from 'pdf-lib';
import { RotateCcw, RotateCw } from '@lucide/svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import SinglePdfTool from '$lib/components/SinglePdfTool.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { createPdfSession } from '$lib/pdf-session.svelte';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
let pdfFile: File | null = $state(null);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let rotations = $state(new Map<number, number>());
let isProcessing = $state(false);
let error = $state<string | null>(null);
let loadingError = $state<string | null>(null);
const session = createPdfSession<PdfDocumentHandle>({
load: (_file, bytes) => loadPdfDocument(bytes),
dispose: (handle) => handle.destroy(),
onFileChange: () => {
rotations = new Map();
error = null;
}
});
const pdfFile = $derived(session.file);
const pdfHandle = $derived(session.handle);
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
const rotatedCount = $derived(rotations.size);
const hasRotations = $derived(rotatedCount > 0);
const isEncryptedPdfError = (loadError: unknown) => {
const message =
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
return /password|encrypted|encryption|verschl/i.test(message);
};
const loadErrorMessage = (loadError: unknown) =>
isEncryptedPdfError(loadError)
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
rotations = new Map();
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 = loadErrorMessage(err);
pdfHandle = null;
}
const handleFileSelected = (files: File[]) => {
const [file] = files;
if (file) void session.load(file);
};
const rotatePage = (pageNumber: number, delta: number) => {
@@ -63,8 +47,7 @@
};
const rotateAll = (delta: number) => {
if (!pdfHandle) return;
for (let pageNumber = 1; pageNumber <= pdfHandle.pageCount; pageNumber += 1) {
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
rotatePage(pageNumber, delta);
}
};
@@ -88,13 +71,14 @@
};
const rotateAndDownload = async () => {
if (!pdfFile || !pdfHandle || !hasRotations) return;
const bytes = session.bytes;
const currentFile = session.file;
if (!bytes || !currentFile || !hasRotations) return;
isProcessing = true;
error = null;
try {
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const pdfDoc = await PDFDocument.load(bytes);
for (const [index, page] of pdfDoc.getPages().entries()) {
const delta = rotations.get(index + 1);
@@ -104,51 +88,31 @@
}
const resultBytes = await pdfDoc.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
const baseName = currentFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_gedreht.pdf`);
} catch (err) {
console.error('Failed to rotate pages:', err);
error = isEncryptedPdfError(err)
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
: 'Fehler beim Drehen der Seiten.';
error = pdfProcessingErrorMessage(err, 'Fehler beim Drehen der Seiten.');
} finally {
isProcessing = false;
}
};
onDestroy(() => {
pdfHandle?.destroy();
session.clear();
});
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Seiten drehen</h1>
<p class="mt-2 text-sm text-primary-700">
Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust.
</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
<SinglePdfTool
title="Seiten drehen"
description="Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust."
file={pdfFile}
{pageCount}
loadingError={session.loadingError}
{error}
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 flex-wrap gap-2">
>
{#snippet actions()}
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(90)}>
Alle 90° rechts
</Button>
@@ -166,10 +130,13 @@
>
Zurücksetzen
</Button>
</div>
</div>
{/snippet}
<PdfPageGrid thumbnails={pdfHandle.thumbnails} thumbnailWidth={160} cardClass={previewClass}>
<PdfPageGrid
thumbnails={pdfHandle?.thumbnails ?? []}
thumbnailWidth={160}
cardClass={previewClass}
>
{#snippet pageActions(pageNumber)}
{#if (rotations.get(pageNumber) ?? 0) !== 0}
<span
@@ -181,7 +148,7 @@
<div class="flex gap-1">
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
class="border-primary-200 hover:bg-primary-50 focus-visible:ring-primary-400 flex h-7 w-7 items-center justify-center rounded-lg border bg-white text-primary transition focus:outline-none focus-visible:ring-2"
aria-label="Seite {pageNumber} um 90° nach links drehen"
onclick={() => rotatePage(pageNumber, 270)}
>
@@ -189,7 +156,7 @@
</button>
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
class="border-primary-200 hover:bg-primary-50 focus-visible:ring-primary-400 flex h-7 w-7 items-center justify-center rounded-lg border bg-white text-primary transition focus:outline-none focus-visible:ring-2"
aria-label="Seite {pageNumber} um 90° nach rechts drehen"
onclick={() => rotatePage(pageNumber, 90)}
>
@@ -199,21 +166,13 @@
{/snippet}
</PdfPageGrid>
{#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">
{rotatedCount} von {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
{#snippet footer()}
<p class="text-primary-700 text-sm">
{rotatedCount} von {pageCount} Seite{pageCount === 1 ? '' : 'n'}
gedreht
</p>
<Button onclick={rotateAndDownload} disabled={isProcessing || !hasRotations}>
{isProcessing ? 'Wird verarbeitet...' : 'Drehen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
{/snippet}
</SinglePdfTool>
+45 -83
View File
@@ -1,38 +1,34 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import SinglePdfTool from '$lib/components/SinglePdfTool.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { copyPagesToNewPdf } from '$lib/pdf-processing';
import { createPdfSession } from '$lib/pdf-session.svelte';
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;
const session = createPdfSession<PdfDocumentHandle>({
load: (_file, bytes) => loadPdfDocument(bytes),
dispose: (handle) => handle.destroy(),
onFileChange: () => {
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 pdfFile = $derived(session.file);
const pdfHandle = $derived(session.handle);
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
const handleFileSelected = (files: File[]) => {
const [file] = files;
if (file) void session.load(file);
};
const togglePage = (pageNumber: number) => {
@@ -46,9 +42,8 @@
};
const selectAll = () => {
if (!pdfHandle) return;
const all = new Set<number>();
for (let i = 1; i <= pdfHandle.pageCount; i++) all.add(i);
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) all.add(pageNumber);
selectedPages = all;
};
@@ -57,18 +52,18 @@
};
const removePages = async () => {
if (!pdfFile || !pdfHandle || selectedPages.size === 0) return;
const bytes = session.bytes;
const currentFile = session.file;
if (!bytes || !currentFile || selectedPages.size === 0) return;
isProcessing = true;
error = null;
try {
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const pagesToKeep: number[] = [];
for (let i = 0; i < pdfHandle.pageCount; i++) {
if (!selectedPages.has(i + 1)) {
pagesToKeep.push(i);
for (let index = 0; index < pageCount; index += 1) {
if (!selectedPages.has(index + 1)) {
pagesToKeep.push(index);
}
}
@@ -79,79 +74,46 @@
}
const resultBytes = await copyPagesToNewPdf(bytes, pagesToKeep);
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
const baseName = currentFile.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.';
error = pdfProcessingErrorMessage(err, 'Fehler beim Entfernen der Seiten.');
} finally {
isProcessing = false;
}
};
onDestroy(() => {
pdfHandle?.destroy();
session.clear();
});
</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">
<SinglePdfTool
title="Seiten entfernen"
description="Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten."
file={pdfFile}
{pageCount}
loadingError={session.loadingError}
{error}
</div>
{/if}
onFilesSelected={handleFileSelected}
>
{#snippet actions()}
<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>
{/snippet}
<div class="flex items-center justify-between">
<p class="text-sm text-primary-700">
<PdfPageGrid thumbnails={pdfHandle?.thumbnails ?? []} {selectedPages} onPageClick={togglePage} />
{#snippet footer()}
<p class="text-primary-700 text-sm">
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
</p>
<Button
onclick={removePages}
disabled={isProcessing ||
selectedPages.size === 0 ||
selectedPages.size >= pdfHandle.pageCount}
disabled={isProcessing || selectedPages.size === 0 || selectedPages.size >= pageCount}
>
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
{/snippet}
</SinglePdfTool>
+206 -271
View File
@@ -1,19 +1,32 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
import { degrees, PDFDocument, StandardFonts, type PDFFont } from 'pdf-lib';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { getPositionCoords, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { createLatestToken } from '$lib/latest-task';
import {
OVERLAY_POSITION_OPTIONS,
getPreviewPositionStyle,
parseHexColor,
type OverlayPosition
} from '$lib/pdf-overlay';
import {
MIN_TEXT_OVERLAY_SIZE,
TEXT_OVERLAY_MARGIN,
TEXT_OVERLAY_PADDING,
drawImageOverlay,
drawTextOverlay
} from '$lib/pdf-text-overlay';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
const PADDING = 8;
const PAGE_MARGIN = 20;
const MIN_EXPORT_FONT_SIZE = 4;
const MIN_EXPORT_IMAGE_SIZE = 20;
type StampImage = {
name: string;
@@ -24,7 +37,7 @@
height: number;
};
let pdfFile: File | null = $state(null);
let items = $state<BatchItem[]>([]);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let stampInput = $state('BEGLAUBIGT');
let withDate = $state(false);
@@ -40,9 +53,9 @@
let selectedPages = $state(new Set<number>());
let currentPage = $state(1);
let isLoading = $state(false);
let isProcessing = $state(false);
let isRunning = $state(false);
let error = $state<string | null>(null);
let handleGeneration = 0;
const loadToken = createLatestToken();
const formatGermanDate = (date: Date) =>
[date.getDate(), date.getMonth() + 1, date.getFullYear()]
@@ -58,55 +71,55 @@
const selectedPreset = $derived(
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
);
const canProcess = $derived(
pdfFile !== null &&
pdfHandle !== null &&
(stampImage !== null || previewText.length > 0) &&
!isLoading &&
!isLoadingImage &&
!isProcessing &&
(applyToAll || selectedPages.size > 0)
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
const stampReady = $derived(stampImage !== null || previewText.length > 0);
const pageSelectionMissing = $derived(
items.length === 1 && pdfHandle !== null && !applyToAll && selectedPages.size === 0
);
const canRun = $derived(
!isRunning && pendingCount > 0 && stampReady && !isLoadingImage && !pageSelectionMissing
);
const previewFontSize = $derived(
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
);
const isEncryptedPdfError = (loadError: unknown) => {
const message =
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
return /password|encrypted|encryption|verschl/i.test(message);
};
const handleFilesSelected = async (files: File[]) => {
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
const additions = files
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
.map((file) => createBatchItem(file));
if (additions.length === 0) return;
const loadErrorMessage = (loadError: unknown) =>
isEncryptedPdfError(loadError)
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
const generation = ++handleGeneration;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
items = [...items, ...additions];
selectedPages = new Set();
currentPage = 1;
error = null;
isLoading = true;
// Thumbnails and page selection are only available for a single file so
// memory stays flat during multi-file runs.
pdfHandle?.destroy();
pdfHandle = null;
const ticket = loadToken.begin();
if (items.length !== 1) {
isLoading = false;
return;
}
const [item] = items;
isLoading = true;
try {
const handle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
if (generation !== handleGeneration) {
const handle = await loadPdfDocument(new Uint8Array(await item.file.arrayBuffer()));
if (!loadToken.isCurrent(ticket) || items.length !== 1) {
handle.destroy();
return;
}
pdfHandle = handle;
} catch (loadError) {
console.error('Failed to load PDF for stamping:', loadError);
if (generation === handleGeneration) error = loadErrorMessage(loadError);
if (loadToken.isCurrent(ticket)) error = pdfLoadErrorMessage(loadError);
} finally {
if (generation === handleGeneration) isLoading = false;
if (loadToken.isCurrent(ticket)) isLoading = false;
}
};
@@ -189,224 +202,132 @@
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
};
const getPreviewPositionStyle = (previewPosition: OverlayPosition) => {
switch (previewPosition) {
case 'center':
return 'left: 50%; top: 50%; transform: translate(-50%, -50%);';
case 'top-left':
return 'left: 5%; top: 5%;';
case 'top-center':
return 'left: 50%; top: 5%; transform: translateX(-50%);';
case 'top-right':
return 'right: 5%; top: 5%;';
case 'bottom-left':
return 'bottom: 5%; left: 5%;';
case 'bottom-center':
return 'bottom: 5%; left: 50%; transform: translateX(-50%);';
case 'bottom-right':
return 'bottom: 5%; right: 5%;';
}
};
const getRotatedStampPlacement = (
pageWidth: number,
pageHeight: number,
boxWidth: number,
boxHeight: number,
stampPosition: OverlayPosition,
angle: number
) => {
const radians = (angle * Math.PI) / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const rotatedCorners = [
{ x: 0, y: 0 },
{ x: boxWidth * cosine, y: boxWidth * sine },
{ x: -boxHeight * sine, y: boxHeight * cosine },
{
x: boxWidth * cosine - boxHeight * sine,
y: boxWidth * sine + boxHeight * cosine
}
];
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
const boundingCoordinates = getPositionCoords(
pageWidth,
pageHeight,
maxX - minX,
maxY - minY,
stampPosition,
PAGE_MARGIN
);
return {
x: boundingCoordinates.x - minX,
y: boundingCoordinates.y - minY,
cosine,
sine
};
};
const fitStampToPage = (
font: PDFFont,
text: string,
requestedSize: number,
pageWidth: number,
pageHeight: number
) => {
const availableWidth = pageWidth - PAGE_MARGIN * 2;
const availableHeight = pageHeight - PAGE_MARGIN * 2;
let size = requestedSize;
while (size > MIN_EXPORT_FONT_SIZE) {
const width = font.widthOfTextAtSize(text, size) + PADDING * 2;
const height = font.heightAtSize(size) + PADDING * 2;
if (width <= availableWidth && height <= availableHeight) break;
size = Math.max(MIN_EXPORT_FONT_SIZE, size - 0.5);
}
const textWidth = font.widthOfTextAtSize(text, size);
const textHeight = font.heightAtSize(size);
if (textWidth + PADDING * 2 > availableWidth || textHeight + PADDING * 2 > availableHeight) {
throw new Error(
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
);
}
return {
size,
textHeight,
boxWidth: textWidth + PADDING * 2,
boxHeight: textHeight + PADDING * 2
};
};
const getImageStampBox = (
image: { width: number; height: number },
pageWidth: number,
pageHeight: number
) => {
const padding = withBorder ? PADDING : 0;
const aspectRatio = image.width / image.height;
let drawHeight = Number(imageSize);
let drawWidth = drawHeight * aspectRatio;
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
const availableHeight = pageHeight - PAGE_MARGIN * 2 - padding * 2;
if (drawWidth > availableWidth || drawHeight > availableHeight) {
const scale = Math.min(availableWidth / drawWidth, availableHeight / drawHeight);
drawWidth *= scale;
drawHeight *= scale;
}
if (drawHeight < MIN_EXPORT_IMAGE_SIZE) {
drawHeight = MIN_EXPORT_IMAGE_SIZE;
drawWidth = drawHeight * aspectRatio;
}
return {
drawWidth,
drawHeight,
boxWidth: drawWidth + padding * 2,
boxHeight: drawHeight + padding * 2,
padding
};
};
const applyStamp = async () => {
if (!pdfFile || !pdfHandle || !canProcess) return;
isProcessing = true;
error = null;
try {
const processFile = async (file: File) => {
// Options are read per file so a change during a run only affects the
// still pending files. With multiple files the stamp is applied to all
// pages of every file.
const stampText = buildStampText(stampInput, withDate, new Date());
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
const numericFontSize = Number(fontSize);
const numericImageSize = Number(imageSize);
const withFrame = withBorder;
const angle = Number(rotation);
const stampPosition = position;
const color = parseHexColor(stampColor);
const currentStampImage = stampImage;
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const embeddedImage = stampImage
? stampImage.mimeType === 'image/png'
? await pdfDoc.embedPng(stampImage.bytes)
: await pdfDoc.embedJpg(stampImage.bytes)
const embeddedImage = currentStampImage
? currentStampImage.mimeType === 'image/png'
? await pdfDoc.embedPng(currentStampImage.bytes)
: await pdfDoc.embedJpg(currentStampImage.bytes)
: null;
const pages = pdfDoc.getPages();
const pageIndexes = applyToAll
const useAllPages = items.length > 1 || applyToAll;
const pageIndexes = useAllPages
? pages.map((_, index) => index)
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
const color = parseHexColor(stampColor);
for (const pageIndex of pageIndexes) {
const page = pages[pageIndex];
if (!page) continue;
const { width, height } = page.getSize();
const angle = Number(rotation);
const box = embeddedImage
? getImageStampBox(embeddedImage, width, height)
: fitStampToPage(font, stampText, Number(fontSize), width, height);
const placement = getRotatedStampPlacement(
width,
height,
box.boxWidth,
box.boxHeight,
position,
angle
if (embeddedImage) {
drawImageOverlay(page, {
image: embeddedImage,
size: numericImageSize,
color,
position: stampPosition,
margin: TEXT_OVERLAY_MARGIN,
padding: TEXT_OVERLAY_PADDING,
framed: withFrame,
rotation: angle
});
continue;
}
drawTextOverlay(page, {
font,
text: stampText,
size: numericFontSize,
color,
position: stampPosition,
margin: TEXT_OVERLAY_MARGIN,
padding: TEXT_OVERLAY_PADDING,
framed: true,
rotation: angle,
minSize: MIN_TEXT_OVERLAY_SIZE,
tooLongMessage:
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
});
}
const bytes = await pdfDoc.save();
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_stempel.pdf` };
};
const stampErrorMessage = (processingError: unknown) =>
pdfProcessingErrorMessage(
processingError,
'Diese PDF konnte nicht gestempelt werden. Prüfen Sie die Datei.'
);
if (embeddedImage ? withBorder : true) {
page.drawRectangle({
x: placement.x,
y: placement.y,
width: box.boxWidth,
height: box.boxHeight,
borderColor: color,
borderWidth: 2,
rotate: degrees(angle)
});
}
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
if (embeddedImage) {
const imageBox = box as { drawWidth: number; drawHeight: number; padding: number };
const offsetX = imageBox.padding;
const offsetY = imageBox.padding;
page.drawImage(embeddedImage, {
x: placement.x + offsetX * placement.cosine - offsetY * placement.sine,
y: placement.y + offsetX * placement.sine + offsetY * placement.cosine,
width: imageBox.drawWidth,
height: imageBox.drawHeight,
rotate: degrees(angle)
});
} else {
const textOffsetX = PADDING;
const textOffsetY = PADDING + (box as { textHeight: number }).textHeight * 0.2;
page.drawText(stampText, {
x: placement.x + textOffsetX * placement.cosine - textOffsetY * placement.sine,
y: placement.y + textOffsetX * placement.sine + textOffsetY * placement.cosine,
size: (box as { size: number }).size,
font,
color,
rotate: degrees(angle)
});
}
}
const startBatch = async () => {
if (isRunning || !canRun) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
const resultBytes = await pdfDoc.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_stempel.pdf`);
} catch (processingError) {
console.error('Failed to stamp PDF:', processingError);
error = isEncryptedPdfError(processingError)
? loadErrorMessage(processingError)
: processingError instanceof Error && processingError.message.startsWith('Der Stempeltext')
? processingError.message
: 'Der Stempel konnte nicht angewendet werden. Prüfen Sie die PDF-Datei.';
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage: stampErrorMessage,
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally {
isProcessing = false;
isRunning = false;
}
};
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: stampErrorMessage
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
const removingPreviewedFile = items.length === 1 && items[0]?.id === id;
items = items.filter((item) => item.id !== id);
if (removingPreviewedFile) {
loadToken.invalidate();
pdfHandle?.destroy();
pdfHandle = null;
selectedPages = new Set();
currentPage = 1;
error = null;
isLoading = false;
}
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
};
onDestroy(() => {
handleGeneration += 1;
loadToken.invalidate();
pdfHandle?.destroy();
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
});
@@ -429,15 +350,20 @@
</div>
<FileDropzone
onFilesSelected={handleFileSelected}
label={pdfHandle ? 'Anderes PDF ablegen' : 'PDF zum Stempeln hinzufügen'}
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'PDFs zum Stempeln hinzufügen' : 'Weitere PDFs hinzufügen'}
sublabel={isLoading
? 'Vorschau wird geladen …'
: (pdfFile?.name ?? 'PDF hier ablegen oder auswählen')}
: items.length === 1
? items[0].file.name
: items.length === 0
? 'PDFs hier ablegen oder auswählen'
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
/>
{#if pdfHandle}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
{#if items.length > 0}
<div class="grid gap-6 {pdfHandle ? 'lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]' : ''}">
<section
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
aria-labelledby="stamp-options-title"
@@ -447,7 +373,8 @@
Stempel einstellen
</h2>
<p class="mt-1 text-xs text-muted-foreground">
Lange Texte werden für kleine Seiten automatisch verkleinert.
Lange Texte werden für kleine Seiten automatisch verkleinert. Die Einstellungen gelten
für alle Dateien der Liste.
</p>
</div>
@@ -570,13 +497,9 @@
bind:value={position}
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
>
<option value="top-left">Oben links</option>
<option value="top-center">Oben mittig</option>
<option value="top-right">Oben rechts</option>
<option value="center">Zentriert</option>
<option value="bottom-left">Unten links</option>
<option value="bottom-center">Unten mittig</option>
<option value="bottom-right">Unten rechts</option>
{#each OVERLAY_POSITION_OPTIONS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-2">
@@ -590,19 +513,34 @@
</div>
</div>
{#if items.length > 1}
<p class="text-sm text-primary" role="note">
Der Stempel wird auf alle Seiten aller Dateien angewendet.
</p>
{:else}
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
Auf alle Seiten anwenden
</label>
{/if}
{#if isRunning}
<p class="text-xs text-primary" role="note">
Änderungen an den Einstellungen gelten für die noch ausstehenden Dateien.
</p>
{/if}
</section>
{#if pdfHandle}
<section
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
aria-labelledby="stamp-preview-title"
>
<div class="flex items-center justify-between gap-3">
<div>
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">
Vorschau
</h2>
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
</div>
<span class="text-xs font-semibold text-primary"
@@ -640,7 +578,7 @@
{:else}
<div
data-testid="stamp-preview"
class="border-2 px-2 py-1 font-sans font-extrabold leading-tight whitespace-nowrap"
class="border-2 px-2 py-1 font-sans leading-tight font-extrabold whitespace-nowrap"
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
>
{previewText}
@@ -673,9 +611,10 @@
</div>
{/if}
</section>
{/if}
</div>
{#if !applyToAll}
{#if pdfHandle && !applyToAll}
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
<div>
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
@@ -689,25 +628,21 @@
</section>
{/if}
<BatchFileList
{items}
zipBaseName="stempel"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
<div class="flex flex-col items-end gap-3">
{#if error}
<p
class="w-full rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
role="alert"
>
{error}
</p>
{/if}
<Button size="large" onclick={applyStamp} disabled={!canProcess}>
{isProcessing ? 'Stempel wird angewendet …' : 'Stempel anwenden & herunterladen'}
<ToolError message={error} class="w-full" />
<Button size="large" onclick={startBatch} disabled={!canRun}>
{isRunning ? 'Stempel wird angewendet …' : 'Stempel anwenden'}
</Button>
</div>
{:else if error}
<p
class="rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
role="alert"
>
{error}
</p>
{:else}
<ToolError message={error} />
{/if}
</div>
+11 -8
View File
@@ -4,14 +4,21 @@
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import ToolError from '$lib/components/ToolError.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { getPositionCoords, parseHexColor, type WatermarkPosition } from '$lib/pdf-overlay';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
type WatermarkKind = 'text' | 'image';
const WATERMARK_FALLBACK_MESSAGE =
'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.';
const watermarkErrorMessage = (error: unknown) =>
pdfProcessingErrorMessage(error, WATERMARK_FALLBACK_MESSAGE);
let items = $state<BatchItem[]>([]);
let watermarkKind = $state<WatermarkKind>('text');
let watermarkText = $state('VERTRAULICH');
@@ -67,7 +74,7 @@
pdfHandle = handle;
} catch (loadError) {
console.error('Failed to load PDF for watermarking:', loadError);
error = 'PDF konnte nicht geladen werden.';
error = pdfLoadErrorMessage(loadError);
}
};
@@ -157,7 +164,7 @@
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.',
errorMessage: watermarkErrorMessage,
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
@@ -174,7 +181,7 @@
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.'
errorMessage: watermarkErrorMessage
});
} finally {
isRunning = false;
@@ -338,11 +345,7 @@
/>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<ToolError message={error} />
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canRun}>
+211
View File
@@ -0,0 +1,211 @@
import { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { PDFDocument } from 'pdf-lib';
import {
createEncryptedPdf,
createPdf,
dropFiles,
expectPdfDownload,
getPageContentStreamCount,
getPageText,
pdfDropzone,
trackPageErrors
} from './helpers';
const row = (page: import('@playwright/test').Page, fileName: string) =>
page.locator(`[data-exhibit-item="${fileName}"]`);
const labelOf = (page: import('@playwright/test').Page, fileName: string) =>
row(page, fileName).getAttribute('data-exhibit-label');
const renamedOf = (page: import('@playwright/test').Page, fileName: string) =>
row(page, fileName).locator('[data-exhibit-filename]').innerText();
test('exhibits numbers dropped PDFs, follows the list order and renames the downloads', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zweiBytes = await createPdf(2, 'Zwei');
const zweiInput = await PDFDocument.load(zweiBytes);
const originalStreamCounts = zweiInput
.getPages()
.map((_, pageIndex) => getPageContentStreamCount(zweiInput, pageIndex));
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: zweiBytes },
{ name: 'drei.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Drei') }
]);
await expect(page.locator('[data-exhibit-item]')).toHaveCount(3);
expect(await labelOf(page, 'eins.pdf')).toBe('Anlage 1');
await page.getByRole('button', { name: 'Anlage K', exact: true }).click();
await page.locator('#exhibit-start-number').fill('6');
await expect(row(page, 'eins.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 6');
await expect(row(page, 'drei.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 8');
expect(await renamedOf(page, 'zwei.pdf')).toBe('→ Anlage_K_07_zwei.pdf');
// Reordering the second file to the front renumbers the pending rows.
await page.getByRole('button', { name: 'zwei.pdf nach oben verschieben' }).click();
await expect(row(page, 'zwei.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 6');
await expect(row(page, 'eins.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 7');
expect(await renamedOf(page, 'zwei.pdf')).toBe('→ Anlage_K_06_zwei.pdf');
// The preview renders only the selected Anlage, page 1, on demand.
await page.getByRole('button', { name: 'Vorschau für zwei.pdf' }).click();
await expect(page.getByTestId('exhibit-preview')).toContainText('Anlage K 6');
await expect(page.getByTestId('exhibit-preview')).toHaveAttribute('data-position', 'top-right');
await expect(page.getByText('Seite 1 von 2')).toBeVisible();
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(3, { timeout: 30_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'zwei.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^Anlage_K_06_zwei\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(2);
expect(getPageText(output, 0)).toContain('Anlage K 6');
// Only the first page is stamped by default; page 2 keeps its stream count.
expect(getPageContentStreamCount(output, 1)).toBe(originalStreamCounts[1]);
expect(getPageText(output, 1)).not.toContain('Anlage K 6');
expect(pageErrors).toEqual([]);
});
test('exhibits switches to three digits from 98 and keeps labels unpadded', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'a.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'A') },
{ name: 'b.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'B') },
{ name: 'c.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'C') }
]);
await page.locator('#exhibit-start-number').fill('98');
await expect(row(page, 'a.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 98');
expect(await renamedOf(page, 'a.pdf')).toBe('→ Anlage_098_a.pdf');
expect(await renamedOf(page, 'c.pdf')).toBe('→ Anlage_100_c.pdf');
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(3, { timeout: 30_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'a.pdf herunterladen' }).click();
const first = await expectPdfDownload(await downloadPromise, /^Anlage_098_a\.pdf$/);
expect(getPageText(await PDFDocument.load(first), 0)).toContain('Anlage 98');
const secondPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'c.pdf herunterladen' }).click();
await expectPdfDownload(await secondPromise, /^Anlage_100_c\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('exhibits keeps the original names and honours the every-page option', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'original.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Original') }
]);
await page.getByRole('checkbox', { name: 'Dateien umbenennen' }).uncheck();
await expect(row(page, 'original.pdf').locator('[data-exhibit-filename]')).toHaveCount(0);
await page.getByRole('checkbox', { name: 'Auf jeder Seite stempeln' }).check();
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'original.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^original\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(getPageText(output, 0)).toContain('Anlage 1');
expect(getPageText(output, 1)).toContain('Anlage 1');
expect(pageErrors).toEqual([]);
});
test('exhibits freezes the number of finished rows and says so', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'erst.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Erst') },
{ name: 'dann.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Dann') }
]);
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
await expect(row(page, 'erst.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 1');
await page.getByRole('button', { name: 'dann.pdf nach oben verschieben' }).click();
await expect(row(page, 'dann.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 2');
await expect(row(page, 'erst.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 1');
expect(await renamedOf(page, 'dann.pdf')).toBe('→ Anlage_02_dann.pdf');
expect(pageErrors).toEqual([]);
});
test('exhibits blocks an empty designation and recovers from a broken file', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'gut.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Gut') },
{ name: 'kaputt.pdf', mimeType: 'application/pdf', buffer: Buffer.from('kein PDF') }
]);
await page.locator('#exhibit-prefix').fill(' ');
await expect(page.getByText('Bitte geben Sie eine Bezeichnung an')).toBeVisible();
await expect(page.getByRole('button', { name: 'Anlagen erstellen', exact: true })).toBeDisabled();
await page.locator('#exhibit-prefix').fill('Anlage B');
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 20_000 });
await expect(row(page, 'kaputt.pdf')).toContainText('Fehler');
await expect(row(page, 'gut.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage B 1');
expect(pageErrors).toEqual([]);
});
test('exhibits bundles all finished Anlagen in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Zwei') }
]);
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^anlagen_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
expect(Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!)))).sort()).toEqual([
'Anlage_01_eins.pdf',
'Anlage_02_zwei.pdf'
]);
expect(pageErrors).toEqual([]);
});
test('exhibits reports encrypted PDFs as row errors and keeps going', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{
name: 'gesperrt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Gesperrt', 'geheim')
},
{ name: 'offen.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Offen') }
]);
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 20_000 });
await expect(row(page, 'gesperrt.pdf')).toContainText('passwortgeschützt');
expect(pageErrors).toEqual([]);
});
+121
View File
@@ -123,6 +123,127 @@ export const getPageContentText = (document: PDFDocument, pageIndex: number) =>
.join('\n');
};
const PDF_STRING_ESCAPES: Record<string, string> = {
n: '\n',
r: '\r',
t: '\t',
b: '\b',
f: '\f',
'(': '(',
')': ')',
'\\': '\\'
};
/** Decodes a single PDF string token (`<hex>` or `(literal)`). */
const decodePdfString = (token: string): string => {
if (token.startsWith('<')) {
return Buffer.from(token.slice(1, -1).replace(/\s/g, ''), 'hex').toString('latin1');
}
const raw = token.slice(1, -1);
let decoded = '';
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index];
if (char !== '\\') {
decoded += char;
continue;
}
const escaped = raw[index + 1];
index += 1;
if (escaped === undefined) break;
if (escaped >= '0' && escaped <= '7') {
let octal = escaped;
while (octal.length < 3 && raw[index + 1] >= '0' && raw[index + 1] <= '7') {
octal += raw[(index += 1)];
}
decoded += String.fromCharCode(Number.parseInt(octal, 8));
continue;
}
decoded += PDF_STRING_ESCAPES[escaped] ?? escaped;
}
return decoded;
};
const PDF_STRING_TOKEN = String.raw`<[0-9a-fA-F\s]*>|\((?:\\.|[^()\\])*\)`;
const TEXT_SHOWING_PATTERN = new RegExp(
String.raw`\[((?:[^\[\]\\]|\\.)*)\]\s*TJ|(${PDF_STRING_TOKEN})\s*(?:Tj|'|")`,
'g'
);
const STRING_TOKEN_PATTERN = new RegExp(PDF_STRING_TOKEN, 'g');
/**
* Decoded operands of every text-showing operator (`Tj`, `TJ`, `'`, `"`).
* pdf-lib writes drawn text as hex strings (`<426C2E2031> Tj`), so the raw
* content stream is not readable without this decoding.
*
* Limitations: other text operators (`Td`, `Tf`, …) and non-text strings are
* ignored. A page that shows text through an operator this helper does not know
* returns `[]`, so `not.toContain(...)` assertions on it prove nothing — assert
* on a positive match instead.
*/
export const getPageTextValues = (document: PDFDocument, pageIndex: number) => {
const content = getPageContentText(document, pageIndex);
const values: string[] = [];
for (const match of content.matchAll(TEXT_SHOWING_PATTERN)) {
const [, arrayBody, singleToken] = match;
if (singleToken !== undefined) {
values.push(decodePdfString(singleToken));
continue;
}
for (const token of arrayBody.matchAll(STRING_TOKEN_PATTERN)) {
values.push(decodePdfString(token[0]));
}
}
return values;
};
export const getPageText = (document: PDFDocument, pageIndex: number) =>
getPageTextValues(document, pageIndex).join('\n');
export type PageTextMatrix = { a: number; b: number; c: number; d: number; x: number; y: number };
/**
* Translation and rotation of every text matrix (`Tm`) in a page's content
* stream, in drawing order. Lets tests assert on the drawing geometry without
* hard-coding the hex-encoded text operands.
*/
export const getPageTextMatrices = (document: PDFDocument, pageIndex: number): PageTextMatrix[] => {
const content = getPageContentText(document, pageIndex);
const matrices: PageTextMatrix[] = [];
for (const match of content.matchAll(
/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) Tm/g
)) {
const [a, b, c, d, x, y] = match.slice(1).map(Number);
matrices.push({ a, b, c, d, x, y });
}
if (matrices.length === 0) throw new Error(`Page ${pageIndex + 1} contains no text matrix.`);
return matrices;
};
/** First text matrix of a page (the only one in a single-mark document). */
export const getPageTextMatrix = (document: PDFDocument, pageIndex: number): PageTextMatrix =>
getPageTextMatrices(document, pageIndex)[0];
/** Angle of a text matrix in degrees, normalised to `0 … 359`. */
export const getPageTextAngle = (matrix: PageTextMatrix) =>
((Math.atan2(matrix.b, matrix.a) * 180) / Math.PI + 360) % 360;
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
document.encrypt({
+197
View File
@@ -0,0 +1,197 @@
import { expect, test, type Page } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import { PAGE_NUMBER_MARGIN } from '../../src/lib/pdf-pagination';
import {
createEncryptedPdf,
createPdf,
createPreRotatedPdf,
dropFiles,
expectPdfDownload,
getPageContentText,
getPageText,
getPageTextAngle,
getPageTextMatrices,
getPageTextValues,
pdfDropzone,
trackPageErrors
} from './helpers';
/**
* Number of text operands equal to `label` on a page. The source documents carry
* their own text, so this counts the stamped sheet label instead of comparing
* the whole page.
*/
const countLabel = (document: PDFDocument, pageIndex: number, label: string) =>
getPageTextValues(document, pageIndex).filter((value) => value === label).length;
const downloadPaginated = async (page: Page, fileName: RegExp) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Paginieren & herunterladen' }).click();
return expectPdfDownload(await downloadPromise, fileName);
};
test('pagination stamps every page with an Aktenzeichen and the configured start number', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
]);
await expect(page.getByText('Seite 1 von 3')).toBeVisible();
await page.locator('#pagination-start').fill('26');
await page.locator('#pagination-text').fill('123 Ja 60/26');
await expect(page.getByTestId('pagination-example')).toContainText(
'Beispiel: 123 Ja 60/26, Bl. 26'
);
await page.getByRole('button', { name: 'Nächste Seite' }).click();
await expect(page.getByText('Seite 2 von 3')).toBeVisible();
await expect(page.getByTestId('pagination-preview')).toContainText('123 Ja 60/26, Bl. 27');
const bytes = await downloadPaginated(page, /^akte_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(3);
// Exactly one label per page: a duplicated draw would still pass `toContain`.
expect(countLabel(output, 0, '123 Ja 60/26, Bl. 26')).toBe(1);
expect(countLabel(output, 1, '123 Ja 60/26, Bl. 27')).toBe(1);
expect(countLabel(output, 2, '123 Ja 60/26, Bl. 28')).toBe(1);
expect(pageErrors).toEqual([]);
});
test('pagination can keep the additional text on the first page only', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
]);
await page.locator('#pagination-start').fill('7');
await page.locator('#pagination-text').fill('AZ 7/26');
await page.locator('#pagination-format').selectOption('s');
await page.getByLabel('Nur auf der ersten Seite').check();
await expect(page.getByTestId('pagination-example')).toContainText('AZ 7/26, S. 7');
await page.getByRole('button', { name: 'Nächste Seite' }).click();
await expect(page.getByTestId('pagination-preview')).toHaveText('S. 8');
const bytes = await downloadPaginated(page, /^akte_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(3);
expect(getPageText(output, 0)).toContain('AZ 7/26, S. 7');
expect(getPageText(output, 1)).toContain('S. 8');
expect(getPageText(output, 1)).not.toContain('AZ 7/26');
expect(getPageText(output, 2)).toContain('S. 9');
expect(pageErrors).toEqual([]);
});
test('pagination supports number-only labels and jumps to a page', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{ name: 'nummern.pdf', mimeType: 'application/pdf', buffer: await createPdf(11, 'Nummern') }
]);
await page.locator('#pagination-start').fill('42');
await page.locator('#pagination-format').selectOption('nummer');
await expect(page.getByTestId('pagination-example')).toHaveText('Beispiel: 42');
await page.getByLabel('Springe zu Seite').fill('11');
await expect(page.getByText('Seite 11 von 11')).toBeVisible();
await expect(page.getByTestId('pagination-preview')).toHaveText('52');
const bytes = await downloadPaginated(page, /^nummern_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(11);
expect(getPageText(output, 0)).toContain('42');
expect(getPageText(output, 10)).toContain('52');
expect(pageErrors).toEqual([]);
});
test('pagination keeps the page number upright on rotated scans', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{
name: 'gedreht.pdf',
mimeType: 'application/pdf',
buffer: await createPreRotatedPdf(2, 'Gedreht')
}
]);
const bytes = await downloadPaginated(page, /^gedreht_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(2);
// `createPreRotatedPdf` stores 420×594 pages and puts /Rotate 90 on page 1, so
// the page is displayed as 594×420. The label is counter-rotated so it reads
// horizontally on screen instead of sideways.
// The stamp is the last text drawn on the page.
const rotated = getPageTextMatrices(output, 0).at(-1)!;
expect(Math.round(getPageTextAngle(rotated))).toBe(90);
expect(countLabel(output, 0, 'Bl. 1')).toBe(1);
// Visual bottom centre maps back into user space as x = stored width − margin.
expect(rotated.x).toBeCloseTo(420 - PAGE_NUMBER_MARGIN, -1);
// …and stays horizontally centred on the displayed width.
expect(Math.abs(rotated.y - 594 / 2)).toBeLessThan(15);
const plain = getPageTextMatrices(output, 1).at(-1)!;
expect(Math.round(getPageTextAngle(plain))).toBe(0);
expect(countLabel(output, 1, 'Bl. 2')).toBe(1);
expect(pageErrors).toEqual([]);
});
test('pagination offers the "Seite" format and applies it to every page', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{ name: 'formate.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Formate') }
]);
await page.locator('#pagination-format').selectOption('seite');
await expect(page.getByTestId('pagination-example')).toHaveText('Beispiel: Seite 1');
const bytes = await downloadPaginated(page, /^formate_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(countLabel(output, 0, 'Seite 1')).toBe(1);
expect(countLabel(output, 1, 'Seite 2')).toBe(1);
expect(pageErrors).toEqual([]);
});
test('pagination explains how to handle a password-protected PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{
name: 'geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
}
]);
await expect(page.getByRole('alert')).toContainText('passwortgeschützt');
await expect(page.getByRole('alert')).toContainText('Passwort entfernen');
expect(pageErrors).toEqual([]);
});
test('merge hands the merged PDF over to pagination', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/merge');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
]);
await page.getByRole('button', { name: 'Zusammenfügen & paginieren' }).click();
await expect(page).toHaveURL(/\/tools\/pagination$/);
await expect(page.getByText('Seite 1 von 3')).toBeVisible({ timeout: 15_000 });
const bytes = await downloadPaginated(page, /^zusammengefuegt_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(3);
expect(getPageText(output, 0)).toContain('Bl. 1');
expect(getPageText(output, 1)).toContain('Bl. 2');
expect(getPageText(output, 2)).toContain('Bl. 3');
expect(pageErrors).toEqual([]);
});
+61 -4
View File
@@ -1,4 +1,6 @@
import { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { PDFDocument } from 'pdf-lib';
import {
createEncryptedPdf,
@@ -29,8 +31,10 @@ test('stamp presets fill the editable text and EILT is applied to every page', a
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'akte.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
@@ -58,8 +62,10 @@ test('stamp custom text adds a German date and only changes selected pages', asy
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
await page.getByRole('button', { name: /Seite 2/ }).click();
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'auswahl.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
@@ -89,8 +95,10 @@ test('stamp uses an image instead of text and makes the border optional', async
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
).not.toBeChecked();
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'bildakte.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(2);
@@ -103,8 +111,18 @@ test('stamp uses an image instead of text and makes the border optional', async
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
// Re-add the file so the changed option applies to a fresh pending row.
await page.getByRole('button', { name: 'bildakte.pdf entfernen' }).click();
await dropFiles(page, pdfDropzone(page), [
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
]);
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const borderedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'bildakte.pdf herunterladen' }).click();
const borderedBytes = await expectPdfDownload(
await borderedDownloadPromise,
/^bildakte_stempel\.pdf$/
@@ -120,6 +138,45 @@ test('stamp uses an image instead of text and makes the border optional', async
expect(pageErrors).toEqual([]);
});
test('stamp batch applies to every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') },
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
},
{
name: 'geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
}
]);
await expect(page.getByText('Dateien (4)')).toBeVisible();
// With several files the stamp is applied to every page and no preview is loaded.
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Vorschau' })).toHaveCount(0);
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
await expect(page.getByText('Fehler')).toHaveCount(2);
await expect(page.getByText('Dieses PDF ist passwortgeschützt')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^stempel_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual(['eins_stempel.pdf', 'zwei_stempel.pdf']);
expect(pageErrors).toEqual([]);
});
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
+1
View File
@@ -12,6 +12,7 @@ test('start page exposes only working tools and forwards a dropped ZIP to beA',
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible();
await expect(page.getByRole('link', { name: /Paginieren/ })).toBeVisible();
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
await dropFiles(page, 'window', [
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test';
import { tools } from '../../src/lib/tools';
/**
* The tool registry, its icon map, the route files and the page headings all
* live in different files. This catches the case where one of them drifts and a
* registered tool 404s or renders under a different title.
*/
for (const tool of tools) {
test(`the registry entry "${tool.title}" renders at /tools/${tool.slug}`, async ({ page }) => {
const response = await page.goto(`/tools/${tool.slug}`);
expect(response?.status()).toBe(200);
await expect(page.getByRole('heading', { level: 1, name: tool.title })).toBeVisible();
});
}
+83
View File
@@ -0,0 +1,83 @@
import { expect, test } from '@playwright/test';
import {
DEFAULT_EXHIBIT_PREFIX,
MAX_EXHIBIT_PREFIX_LENGTH,
MAX_EXHIBIT_START_NUMBER,
MIN_EXHIBIT_START_NUMBER,
buildExhibitFileName,
buildExhibitLabel,
buildExhibitPrefixSlug,
getExhibitNumberWidth,
normalizeExhibitPrefix,
normalizeExhibitStartNumber
} from '../../src/lib/exhibit-naming';
test('the prefix is trimmed and capped', () => {
expect(normalizeExhibitPrefix(' Anlage K ')).toBe('Anlage K');
expect(normalizeExhibitPrefix('')).toBe('');
expect(normalizeExhibitPrefix('A'.repeat(MAX_EXHIBIT_PREFIX_LENGTH + 10))).toHaveLength(
MAX_EXHIBIT_PREFIX_LENGTH
);
});
test('labels use one space and never pad the number', () => {
expect(buildExhibitLabel('Anlage K', 6)).toBe('Anlage K 6');
expect(buildExhibitLabel(' Anlage ', 1)).toBe('Anlage 1');
expect(buildExhibitLabel('Anlage', 98)).toBe('Anlage 98');
});
test('the padding width follows the highest assigned number', () => {
expect(getExhibitNumberWidth(9)).toBe(2);
expect(getExhibitNumberWidth(99)).toBe(2);
expect(getExhibitNumberWidth(100)).toBe(3);
expect(getExhibitNumberWidth(1000)).toBe(4);
});
test('file names prefix the number and keep the original base name', () => {
expect(
buildExhibitFileName({
prefix: 'Anlage K',
number: 6,
highestNumber: 8,
originalName: 'Klageschrift.pdf'
})
).toBe('Anlage_K_06_Klageschrift.pdf');
expect(
buildExhibitFileName({
prefix: 'Anlage K',
number: 98,
highestNumber: 100,
originalName: 'Anlage.PDF'
})
).toBe('Anlage_K_098_Anlage.pdf');
});
test('unsafe characters never reach the file name', () => {
expect(
buildExhibitFileName({
prefix: 'Anlage K/1',
number: 1,
highestNumber: 1,
originalName: 'a:b*c?.pdf'
})
).toBe('Anlage_K1_01_abc.pdf');
});
test('an empty prefix falls back to the default slug', () => {
expect(buildExhibitPrefixSlug(' ')).toBe(DEFAULT_EXHIBIT_PREFIX);
expect(
buildExhibitFileName({ prefix: '', number: 3, highestNumber: 3, originalName: 'x.pdf' })
).toBe('Anlage_03_x.pdf');
});
test('the start number is clamped and survives an empty field', () => {
expect(normalizeExhibitStartNumber(1)).toBe(1);
expect(normalizeExhibitStartNumber(0)).toBe(MIN_EXHIBIT_START_NUMBER);
expect(normalizeExhibitStartNumber(-5)).toBe(MIN_EXHIBIT_START_NUMBER);
expect(normalizeExhibitStartNumber(6.6)).toBe(7);
expect(normalizeExhibitStartNumber(undefined)).toBe(1);
expect(normalizeExhibitStartNumber(null)).toBe(1);
expect(normalizeExhibitStartNumber('')).toBe(1);
expect(normalizeExhibitStartNumber(1_000_000)).toBe(MAX_EXHIBIT_START_NUMBER);
});
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
import {
ENCRYPTED_PDF_MESSAGE,
TextOverlayError,
classifyPdfError,
pdfLoadErrorMessage,
pdfProcessingErrorMessage
} from '../../src/lib/pdf-errors';
const encryptedError = () =>
new Error(
'Input document to `PDFDocument.load` is encrypted. You can use `PDFDocument.load(..., { ignoreEncryption: true })`'
);
test('overlay failures keep their own copy', () => {
const error = new TextOverlayError('Der Text ist zu lang.');
expect(classifyPdfError(error)).toBe('text-overlay');
expect(pdfProcessingErrorMessage(error, 'Fallback')).toBe('Der Text ist zu lang.');
expect(pdfLoadErrorMessage(error)).toBe('Der Text ist zu lang.');
});
test('encrypted PDFs map to the shared password hint', () => {
expect(classifyPdfError(encryptedError())).toBe('encrypted');
expect(pdfProcessingErrorMessage(encryptedError(), 'Fallback')).toBe(ENCRYPTED_PDF_MESSAGE);
expect(pdfLoadErrorMessage(encryptedError())).toBe(ENCRYPTED_PDF_MESSAGE);
});
test('unknown failures fall back to the caller copy', () => {
const error = new Error('Invalid PDF structure');
expect(classifyPdfError(error)).toBe('unknown');
expect(pdfProcessingErrorMessage(error, 'Fehler beim Drehen der Seiten.')).toBe(
'Fehler beim Drehen der Seiten.'
);
expect(pdfLoadErrorMessage(error, 'Eigene Meldung.')).toBe('Eigene Meldung.');
expect(pdfLoadErrorMessage(error)).toContain('PDF konnte nicht geladen werden');
// Tool-specific copy must never be overridden by the classification.
expect(pdfProcessingErrorMessage('boom', 'Eigene Meldung.')).toBe('Eigene Meldung.');
});
+131
View File
@@ -0,0 +1,131 @@
import { expect, test } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import { TextOverlayError } from '../../src/lib/pdf-errors';
import type { OverlayPosition } from '../../src/lib/pdf-overlay';
import {
MAX_FREE_TEXT_LENGTH,
PAGINATION_FORMATS,
buildPaginationLabel,
normalizeFreeText,
normalizeStartNumber,
paginatePdf,
type PaginationFormat,
type PaginationOptions,
type PaginationTextScope
} from '../../src/lib/pdf-pagination';
import { getPageTextValues } from '../e2e/helpers';
const labelOptions = (
overrides: Partial<{
startNumber: number;
freeText: string;
textScope: PaginationTextScope;
format: PaginationFormat;
}> = {}
) => ({
startNumber: 1,
freeText: '',
textScope: 'all' as const,
format: 'blatt' as const,
...overrides
});
const paginationOptions = (overrides: Partial<PaginationOptions> = {}): PaginationOptions => ({
startNumber: 1,
freeText: '',
textScope: 'all',
format: 'blatt',
position: 'bottom-center' as OverlayPosition,
fontSize: 11,
...overrides
});
/** Blank pages, so the stamped sheet number is the only text in the output. */
const createPdf = async (pageCount: number, size: [number, number] = [420, 594]) => {
const document = await PDFDocument.create();
for (let index = 0; index < pageCount; index += 1) document.addPage(size);
return new Uint8Array(await document.save());
};
test('every format is offered with a rendered example label', () => {
expect(PAGINATION_FORMATS.map((format) => format.value)).toEqual([
'blatt',
'seite',
's',
'nummer'
]);
expect(PAGINATION_FORMATS.map((format) => format.label)).toEqual([
'Bl. 1',
'Seite 1',
'S. 1',
'1'
]);
});
test('label combines the free text with the ascending sheet number', () => {
expect(buildPaginationLabel(0, labelOptions({ startNumber: 26 }))).toBe('Bl. 26');
expect(buildPaginationLabel(2, labelOptions({ startNumber: 26 }))).toBe('Bl. 28');
expect(buildPaginationLabel(0, labelOptions({ freeText: ' 123 Ja 60/26 ' }))).toBe(
'123 Ja 60/26, Bl. 1'
);
expect(buildPaginationLabel(0, labelOptions({ format: 'seite', startNumber: 6 }))).toBe(
'Seite 6'
);
expect(buildPaginationLabel(0, labelOptions({ format: 's', startNumber: 6 }))).toBe('S. 6');
expect(buildPaginationLabel(0, labelOptions({ format: 'nummer', startNumber: 6 }))).toBe('6');
// No leading zeros — a start number of 6 is not "06".
expect(buildPaginationLabel(0, labelOptions({ startNumber: 6 }))).toBe('Bl. 6');
});
test('the free text is dropped on later pages for the first-page scope', () => {
const options = labelOptions({ freeText: 'AZ 7/26', textScope: 'first', format: 's' });
expect(buildPaginationLabel(0, options)).toBe('AZ 7/26, S. 1');
expect(buildPaginationLabel(1, options)).toBe('S. 2');
});
test('start number and free text are normalised at the boundary', () => {
expect(normalizeStartNumber(0)).toBe(1);
expect(normalizeStartNumber(-5)).toBe(1);
expect(normalizeStartNumber(2.6)).toBe(3);
expect(normalizeStartNumber(Number.NaN)).toBe(1);
expect(normalizeStartNumber(1_000_000)).toBe(99_999);
expect(normalizeFreeText(' 123 Ja 60/26 ')).toBe('123 Ja 60/26');
expect(normalizeFreeText('x'.repeat(90))).toHaveLength(MAX_FREE_TEXT_LENGTH);
});
test('paginatePdf stamps each page exactly once', async () => {
const output = await paginatePdf(
await createPdf(3),
paginationOptions({ startNumber: 26, freeText: '123 Ja 60/26' })
);
const document = await PDFDocument.load(output);
expect(document.getPageCount()).toBe(3);
expect(getPageTextValues(document, 0)).toEqual(['123 Ja 60/26, Bl. 26']);
expect(getPageTextValues(document, 1)).toEqual(['123 Ja 60/26, Bl. 27']);
expect(getPageTextValues(document, 2)).toEqual(['123 Ja 60/26, Bl. 28']);
});
test('paginatePdf honours the first-page-only scope', async () => {
const output = await paginatePdf(
await createPdf(2),
paginationOptions({ freeText: 'AZ 7/26', textScope: 'first', format: 'nummer' })
);
const document = await PDFDocument.load(output);
expect(getPageTextValues(document, 0)).toEqual(['AZ 7/26, 1']);
expect(getPageTextValues(document, 1)).toEqual(['2']);
});
test('a label that cannot fit at the minimum font size fails loudly', async () => {
await expect(
paginatePdf(
await createPdf(1, [120, 160]),
paginationOptions({ freeText: 'x'.repeat(MAX_FREE_TEXT_LENGTH), fontSize: 18 })
)
).rejects.toBeInstanceOf(TextOverlayError);
});
+133
View File
@@ -0,0 +1,133 @@
import { expect, test } from '@playwright/test';
import { degrees, PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { TextOverlayError } from '../../src/lib/pdf-errors';
import {
TEXT_OVERLAY_MARGIN,
drawTextOverlay,
getRotatedBoxPlacement
} from '../../src/lib/pdf-text-overlay';
import { getPageText, getPageTextAngle, getPageTextMatrix } from '../e2e/helpers';
const PAGE_WIDTH = 420;
const PAGE_HEIGHT = 594;
const MARGIN = 28;
/**
* Maps a point from default user space to the displayed page. Viewers rotate the
* content clockwise by `/Rotate`, so this is the forward transformation.
*/
const toVisualPoint = (
point: { x: number; y: number },
rotation: number,
width: number,
height: number
) => {
switch (rotation) {
case 90:
return { x: point.y, y: width - point.x };
case 180:
return { x: width - point.x, y: height - point.y };
case 270:
return { x: height - point.y, y: point.x };
default:
return point;
}
};
const createPageWithMark = async (rotation: number) => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
const page = document.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
page.setRotation(degrees(rotation));
drawTextOverlay(page, {
font,
text: 'Bl. 1',
size: 11,
color: rgb(0.1, 0.1, 0.1),
position: 'bottom-center',
margin: MARGIN,
compensatePageRotation: true
});
return PDFDocument.load(await document.save());
};
for (const rotation of [0, 90, 180, 270]) {
test(`bottom-center mark stays at the visual bottom on a /Rotate ${rotation} page`, async () => {
const output = await createPageWithMark(rotation);
const matrix = getPageTextMatrix(output, 0);
const visualWidth = rotation % 180 === 0 ? PAGE_WIDTH : PAGE_HEIGHT;
const origin = toVisualPoint({ x: matrix.x, y: matrix.y }, rotation, PAGE_WIDTH, PAGE_HEIGHT);
// Horizontally centred on the displayed page.
expect(Math.abs(origin.x - visualWidth / 2)).toBeLessThan(30);
// Close to the bottom margin (the baseline sits a few points above it).
expect(origin.y).toBeGreaterThan(MARGIN - 2);
expect(origin.y).toBeLessThan(MARGIN + 20);
// The glyphs read horizontally: the drawn angle cancels the page rotation.
expect(Math.round(getPageTextAngle(matrix))).toBe(rotation);
expect(getPageText(output, 0)).toBe('Bl. 1');
});
}
test('a mark that does not fit raises a TextOverlayError instead of shrinking forever', async () => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
const page = document.addPage([120, 160]);
expect(() =>
drawTextOverlay(page, {
font,
text: 'Ein sehr langer Stempeltext, der nicht auf die Seite passt',
size: 18,
color: rgb(0, 0, 0),
position: 'bottom-center',
margin: MARGIN,
framed: true
})
).toThrow(TextOverlayError);
});
test('characters the standard font cannot encode are reported as such', async () => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
const page = document.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
let thrown: unknown;
try {
drawTextOverlay(page, {
font,
text: 'Beschluss \u{1F600}',
size: 11,
color: rgb(0, 0, 0),
position: 'top-right',
margin: MARGIN
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(TextOverlayError);
expect((thrown as Error).message).toContain('nicht dargestellt werden');
});
test('a rotated mark keeps its bounding box inside the page', async () => {
const boxWidth = 200;
const boxHeight = 40;
for (const angle of [0, 45, 90, 135]) {
const placement = getRotatedBoxPlacement(
PAGE_WIDTH,
PAGE_HEIGHT,
boxWidth,
boxHeight,
'bottom-center',
angle,
TEXT_OVERLAY_MARGIN
);
expect(placement.x).toBeGreaterThanOrEqual(TEXT_OVERLAY_MARGIN - 0.001);
expect(placement.y).toBeGreaterThanOrEqual(TEXT_OVERLAY_MARGIN - 0.001);
expect(placement).not.toHaveProperty('cosine');
}
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';
import {
clearPendingFiles,
queuePendingFiles,
takePendingFiles
} from '../../src/lib/pending-files';
const pdf = () => new File(['%PDF-1.7'], 'akte.pdf', { type: 'application/pdf' });
const zip = () => new File(['PK'], 'archiv.zip', { type: 'application/zip' });
test.beforeEach(() => clearPendingFiles());
test('a target only receives the files queued for it', () => {
queuePendingFiles([zip()], 'bea');
queuePendingFiles([pdf()], 'pagination');
const paginationFiles = takePendingFiles('pagination');
expect(paginationFiles.map((file) => file.name)).toEqual(['akte.pdf']);
// The beA handoff must survive a pagination visit instead of being dropped.
expect(takePendingFiles('bea').map((file) => file.name)).toEqual(['archiv.zip']);
});
test('taking a target clears only that target', () => {
queuePendingFiles([pdf(), pdf()], 'pagination');
expect(takePendingFiles('pagination')).toHaveLength(2);
expect(takePendingFiles('pagination')).toHaveLength(0);
});