Features/page numbers #4

Merged
rehlert merged 2 commits from features/page-numbers into develop 2026-10-28 15:11:48 +00:00
19 changed files with 1823 additions and 339 deletions
Showing only changes of commit b5279434b2 - Show all commits

No files matched your search

+30 -13
View File
@@ -90,10 +90,13 @@ src/
│ │ └── zip-inflating.service.ts # Async fflate wrapper │ │ └── zip-inflating.service.ts # Async fflate wrapper
│ ├── batch.ts # Sequential batch engine for multi-file tools │ ├── batch.ts # Sequential batch engine for multi-file tools
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline │ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
│ ├── pdf-errors.ts # Shared encrypted-PDF detection and German messages
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only) │ ├── 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-processing.ts # Reusable PDF merge helper │ ├── pdf-processing.ts # Reusable PDF merge helper
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup │ ├── pdf-text-overlay.ts # Shared framed/rotated text overlay drawing
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnails, on-demand page previews, cleanup
│ ├── utils.ts # cn() and shared component utility types │ ├── utils.ts # cn() and shared component utility types
│ └── zip-processing.ts # Archive model, transforms, and PDF export │ └── zip-processing.ts # Archive model, transforms, and PDF export
└── routes/ └── routes/
@@ -104,7 +107,7 @@ src/
├── tools/ ├── tools/
│ ├── +layout.svelte # Shared back-navigation shell │ ├── +layout.svelte # Shared back-navigation shell
│ ├── bea/+page.svelte # Existing ZIP archive workflow │ ├── bea/+page.svelte # Existing ZIP archive workflow
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/ │ └── {pagination,merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
├── datenschutz/+page.svelte # Privacy policy ├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint └── impressum/+page.svelte # Imprint
static/ # Public logo, footer art, PWA icons, and robots.txt static/ # Public logo, footer art, PWA icons, and robots.txt
@@ -146,7 +149,13 @@ committed.
## Application flow and state ## Application flow and state
- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and - `/` 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/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document - `/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' }` 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. result for password-protected files, and detects embedded-file markers by scanning raw bytes.
@@ -231,6 +240,13 @@ All case-file processing is intentionally local to the browser.
- Page-set copies (separate/extract tools, PDF splitting) share - Page-set copies (separate/extract tools, PDF splitting) share
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy `copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
semantics cannot drift apart. 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. Share it instead
of re-implementing overlay maths in a route.
- `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 revoke object
URLs and tear down the pdfjs loading task in `destroy()`.
- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before - `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 rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
safeguards. safeguards.
@@ -243,15 +259,15 @@ called in the browser.
## Batch processing pattern for single-PDF tools ## Batch processing pattern for single-PDF tools
Tools that transform one PDF per run (compress, watermark, encrypt, …) are being retrofitted to Tools that transform one PDF per run (compress, watermark, stamp, encrypt, decrypt) accept multiple
accept multiple files. Follow the established compress retrofit when adding the pattern to another files. Follow the established compress retrofit when adding the pattern to another tool; see
tool; see `plan/batch-processing-plan.md` for the full design. `plan/done/batch-processing-plan.md` for the full design.
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update, - `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 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 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 error message (a string, or a formatter that can inspect the thrown error) — and consults the
skipped. optional `shouldProcess` predicate so rows removed mid-run are skipped.
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip - `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip
(Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with (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 accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a
@@ -262,10 +278,11 @@ options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PD
deduplicate by name + size), pass a `process(file)` callback that reads the current option state 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. 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 Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
of blocking the batch. of blocking the batch. Keep thumbnails, previews, and page-specific selection to a single dropped
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, encrypt file; multi-file runs apply the tool to every page of every file.
`geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the batch - Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, stamp `stempel`,
flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal). 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 ## Svelte conventions
+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.
+7 -3
View File
@@ -12,8 +12,11 @@ export type BatchItem = {
export type BatchProcessResult = { bytes: Uint8Array; name: string }; export type BatchProcessResult = { bytes: Uint8Array; name: string };
export type BatchRunOptions = { 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` * Optional predicate consulted before each item; when it returns `false`
* (for example because the row was removed mid-run), the item is skipped. * (for example because the row was removed mid-run), the item is skipped.
@@ -58,7 +61,8 @@ export const runBatch = async (
status: 'error', status: 'error',
resultBytes: undefined, resultBytes: undefined,
resultName: undefined, resultName: undefined,
errorMessage errorMessage:
typeof errorMessage === 'function' ? errorMessage(error, item.file) : errorMessage
}); });
} }
} }
+26
View File
@@ -0,0 +1,26 @@
const ENCRYPTED_PDF_PATTERN = /password|encrypted|encryption|verschl/i;
/**
* 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);
};
export const ENCRYPTED_PDF_MESSAGE =
'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.';
export const pdfLoadErrorMessage = (error: unknown): string =>
isEncryptedPdfError(error)
? ENCRYPTED_PDF_MESSAGE
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
/** Shared row/banner message for PDFs that could not be transformed. */
export const pdfProcessingErrorMessage = (error: unknown, fallback: string): string => {
if (isEncryptedPdfError(error)) return ENCRYPTED_PDF_MESSAGE;
if (error instanceof Error && /^Der (Stempel)?text/.test(error.message)) return error.message;
return fallback;
};
+23
View File
@@ -50,3 +50,26 @@ export const parseHexColor = (hexColor: string): RGB => {
Number.parseInt(match[3], 16) / 255 Number.parseInt(match[3], 16) / 255
); );
}; };
/**
* CSS for the absolutely positioned overlay used by browser previews. The 5%
* inset approximates the PDF margin at reading size.
*/
export const getPreviewPositionStyle = (position: OverlayPosition): string => {
switch (position) {
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%;';
}
};
+81
View File
@@ -0,0 +1,81 @@
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;
export const PAGINATION_FORMATS = [
{ value: 'blatt', label: 'Bl. {n}', prefix: 'Bl. ' },
{ value: 'seite', label: 'Seite {n}', prefix: 'Seite ' },
{ value: 's', label: 'S. {n}', prefix: 'S. ' },
{ value: 'nummer', label: 'Nur Nummer', prefix: '' }
] as const;
export type PaginationFormat = (typeof PAGINATION_FORMATS)[number]['value'];
export type PaginationTextScope = 'all' | 'first';
export type PaginationOptions = {
startNumber: number;
freeText: string;
textScope: PaginationTextScope;
format: PaginationFormat;
position: OverlayPosition;
fontSize: number;
};
export const normalizeStartNumber = (value: number): number =>
Math.min(
MAX_START_NUMBER,
Math.max(MIN_START_NUMBER, Math.round(Number(value) || MIN_START_NUMBER))
);
const getFormatPrefix = (format: PaginationFormat): string =>
(PAGINATION_FORMATS.find((entry) => entry.value === format) ?? PAGINATION_FORMATS[0]).prefix;
/**
* Sheet label for one page: `123 Ja 60/26, Bl. 27`. The number always appears;
* the free text only on the first page when that scope is selected.
*/
export const buildPaginationLabel = (
pageIndex: number,
options: Pick<PaginationOptions, 'startNumber' | 'freeText' | 'textScope' | 'format'>
): string => {
const numberText = `${getFormatPrefix(options.format)}${normalizeStartNumber(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.
*/
export const paginatePdf = async (
bytes: Uint8Array,
options: PaginationOptions
): Promise<Uint8Array> => {
const document = await PDFDocument.load(new Uint8Array(bytes));
const font = await document.embedFont(StandardFonts.Helvetica);
const color = parseHexColor(PAGE_NUMBER_COLOR);
for (const [pageIndex, page] of document.getPages().entries()) {
drawTextOverlay(page, {
font,
text: buildPaginationLabel(pageIndex, options),
size: options.fontSize,
color,
position: options.position,
margin: PAGE_NUMBER_MARGIN,
padding: 0,
compensatePageRotation: true
});
}
return document.save();
};
+248
View File
@@ -0,0 +1,248 @@
import { degrees, type PDFFont, type PDFPage, type RGB } from 'pdf-lib';
import { getPositionCoords, type OverlayPosition } from './pdf-overlay';
const DEFAULT_MARGIN = 20;
const DEFAULT_PADDING = 8;
const DEFAULT_MIN_SIZE = 4;
const DEFAULT_BORDER_WIDTH = 2;
/** 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.';
export type TextOverlayOptions = {
font: PDFFont;
text: string;
size: number;
color: RGB;
position: OverlayPosition;
/** Distance between the mark and the page edge in points. */
margin?: number;
/** Inner box padding; use `0` for a frameless, anchor-aligned mark. */
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.
*/
compensatePageRotation?: boolean;
tooLongMessage?: string;
};
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 {
throw new Error(
'Der Text enthält Zeichen, die nicht dargestellt werden können. Entfernen Sie z. B. Emoji.'
);
}
};
const fitTextToPage = (
font: PDFFont,
text: string,
requestedSize: number,
pageWidth: number,
pageHeight: number,
margin: number,
padding: number,
minSize: number,
tooLongMessage: string
): TextBox => {
const availableWidth = pageWidth - margin * 2;
const availableHeight = pageHeight - 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 Error(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 = DEFAULT_MARGIN
) => {
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,
cosine,
sine
};
};
/** 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.
*/
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;
}
};
/**
* Draws a single line of text (optionally framed) at one of the standard
* positions. Shrinks the font to fit the page and throws a German error instead
* of clipping.
*/
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): void => {
const {
font,
text,
size,
color,
position,
margin = DEFAULT_MARGIN,
padding = DEFAULT_PADDING,
framed = false,
borderWidth = DEFAULT_BORDER_WIDTH,
rotation = 0,
minSize = DEFAULT_MIN_SIZE,
compensatePageRotation = false,
tooLongMessage = DEFAULT_TOO_LONG_MESSAGE
} = options;
const { width, height } = page.getSize();
const pageRotation = compensatePageRotation ? getPageRotation(page) : 0;
const visual = getVisualPageSize(width, height, pageRotation);
const box = fitTextToPage(
font,
text,
size,
visual.width,
visual.height,
margin,
padding,
minSize,
tooLongMessage
);
const placement = getRotatedBoxPlacement(
visual.width,
visual.height,
box.boxWidth,
box.boxHeight,
position,
rotation,
margin
);
const origin = toUserSpace({ x: placement.x, y: placement.y }, width, height, pageRotation);
const angle = rotation + pageRotation;
const radians = (angle * Math.PI) / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const offsetX = padding;
const offsetY = padding + box.textHeight * BASELINE_OFFSET_RATIO;
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 + offsetX * cosine - offsetY * sine,
y: origin.y + offsetX * sine + offsetY * cosine,
size: box.size,
font,
color,
rotate: degrees(angle)
});
};
+100 -9
View File
@@ -11,7 +11,27 @@ export type PdfDocumentHandle = {
destroy: () => void; 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;
export type PdfPagePreview = {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
};
/**
* 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<PdfPagePreview>;
destroy: () => void;
};
const loadPdfJs = async () => {
const pdfjsLib = await import('pdfjs-dist'); const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib; const { getDocument, GlobalWorkerOptions } = pdfjsLib;
@@ -19,6 +39,20 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
(module) => module.default (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'
);
});
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
const getDocument = await loadPdfJs();
const loadingTask = getDocument({ data: new Uint8Array(data) }); const loadingTask = getDocument({ data: new Uint8Array(data) });
const thumbnails: PdfThumbnail[] = []; const thumbnails: PdfThumbnail[] = [];
@@ -36,17 +70,10 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.'); if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
await page.render({ canvas, canvasContext, viewport }).promise; 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({ thumbnails.push({
pageNumber, pageNumber,
imageUrl: URL.createObjectURL(blob), imageUrl: URL.createObjectURL(await canvasToPngBlob(canvas)),
width: viewport.width, width: viewport.width,
height: viewport.height height: viewport.height
}); });
@@ -69,3 +96,67 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
throw error; throw error;
} }
} }
export async function loadPdfPageRenderer(data: Uint8Array): Promise<PdfPageRenderer> {
const getDocument = await loadPdfJs();
const loadingTask = getDocument({ data: new Uint8Array(data) });
const pdfDocument = await loadingTask.promise;
let destroyed = false;
let renderToken = 0;
let currentUrl: string | null = null;
const releaseUrl = () => {
if (currentUrl) URL.revokeObjectURL(currentUrl);
currentUrl = null;
};
return {
pageCount: pdfDocument.numPages,
render: async (pageNumber) => {
if (destroyed) throw new Error('Die PDF-Vorschau wurde bereits geschlossen.');
const token = ++renderToken;
const clampedPage = Math.min(
pdfDocument.numPages,
Math.max(1, Math.round(Number(pageNumber) || 1))
);
const page = await pdfDocument.getPage(clampedPage);
const viewport = page.getViewport({ scale: PDF_PREVIEW_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 preview canvas context.');
await page.render({ canvas, canvasContext, viewport }).promise;
const imageUrl = URL.createObjectURL(await canvasToPngBlob(canvas));
// A newer render (or unmount) superseded this one: drop the bitmap
// instead of leaving an orphaned object URL behind.
if (destroyed || token !== renderToken) {
URL.revokeObjectURL(imageUrl);
throw new Error('Die PDF-Vorschau wurde zwischenzeitlich geändert.');
}
releaseUrl();
currentUrl = imageUrl;
return {
pageNumber: clampedPage,
imageUrl,
width: viewport.width,
height: viewport.height
};
},
destroy: () => {
if (destroyed) return;
destroyed = true;
renderToken += 1;
releaseUrl();
void loadingTask.destroy();
}
};
}
+11 -6
View File
@@ -1,11 +1,16 @@
let pendingZipFiles: File[] = []; let pendingFiles: File[] = [];
export const queueZipFiles = (files: File[]) => { /**
pendingZipFiles = [...pendingZipFiles, ...files]; * In-memory handoff between routes (start page → beA workspace, merge →
* pagination). Nothing is uploaded or persisted; the receiving route takes and
* clears the queue on mount.
*/
export const queuePendingFiles = (files: File[]) => {
pendingFiles = [...pendingFiles, ...files];
}; };
export const takeQueuedZipFiles = () => { export const takePendingFiles = () => {
const files = pendingZipFiles; const files = pendingFiles;
pendingZipFiles = []; pendingFiles = [];
return files; return files;
}; };
+7
View File
@@ -12,6 +12,13 @@ export const tools: Tool[] = [
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.', description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
icon: 'stamp' icon: 'stamp'
}, },
{
slug: 'pagination',
title: 'Paginieren',
description:
'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
icon: 'list-ordered'
},
{ {
slug: 'watermark', slug: 'watermark',
title: 'Wasserzeichen', title: 'Wasserzeichen',
+12 -10
View File
@@ -9,6 +9,7 @@
Scissors, Scissors,
Merge, Merge,
Stamp, Stamp,
ListOrdered,
RotateCw, RotateCw,
Info, Info,
FileText FileText
@@ -16,7 +17,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import FileDropzone from '$lib/components/FileDropzone.svelte'; import FileDropzone from '$lib/components/FileDropzone.svelte';
import * as Card from '$lib/components/ui/card/index'; 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 { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
import { tools } from '$lib/tools'; import { tools } from '$lib/tools';
@@ -29,6 +30,7 @@
scissors: Scissors, scissors: Scissors,
merge: Merge, merge: Merge,
stamp: Stamp, stamp: Stamp,
'list-ordered': ListOrdered,
'rotate-cw': RotateCw, 'rotate-cw': RotateCw,
info: Info info: Info
}; };
@@ -37,7 +39,7 @@
const workspaceFiles = files.filter(isAcceptedWorkspaceFile); const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
if (workspaceFiles.length === 0) return; if (workspaceFiles.length === 0) return;
queueZipFiles(workspaceFiles); queuePendingFiles(workspaceFiles);
void goto('/tools/bea'); void goto('/tools/bea');
}; };
@@ -62,12 +64,12 @@
<div class="flex flex-col gap-10 px-4 py-10"> <div class="flex flex-col gap-10 px-4 py-10">
<!-- Hero --> <!-- Hero -->
<div class="flex flex-col items-center gap-2 text-center"> <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> <h1 class="text-primary-900 text-[28px] font-bold">Werkzeugkasten für beA</h1>
<p class="max-w-lg text-[15px] font-medium text-primary-700"> <p class="text-primary-700 max-w-lg text-[15px] font-medium">
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
praktische PDF-Werkzeuge. praktische PDF-Werkzeuge.
</p> </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. Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
</p> </p>
</div> </div>
@@ -84,7 +86,7 @@
icon={Archive} icon={Archive}
class="py-10" 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 Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs
können direkt in Teildokumente aufgeteilt werden. können direkt in Teildokumente aufgeteilt werden.
</p> </p>
@@ -92,12 +94,12 @@
<!-- Tool grid --> <!-- Tool grid -->
<div class="mx-auto w-full max-w-4xl"> <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"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each tools as tool} {#each tools as tool}
{@const Icon = iconMap[tool.icon] ?? FileText} {@const Icon = iconMap[tool.icon] ?? FileText}
<a href="/tools/{tool.slug}" class="block"> <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"> <Card.Content class="flex flex-col gap-3 py-6">
<div <div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary" class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
@@ -105,8 +107,8 @@
<Icon class="h-5 w-5" /> <Icon class="h-5 w-5" />
</div> </div>
<div> <div>
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3> <h3 class="text-primary-900 text-[15px] font-bold">{tool.title}</h3>
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p> <p class="text-primary-700 mt-1 text-[13px] leading-snug">{tool.description}</p>
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
+3 -3
View File
@@ -14,7 +14,7 @@
} from '$lib/zip-processing'; } from '$lib/zip-processing';
import { isAcceptedPdfFile } from '$lib/zip-selection'; import { isAcceptedPdfFile } from '$lib/zip-selection';
import { downloadBlob } from '$lib/download'; 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'; import * as Card from '$lib/components/ui/card/index';
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth'; const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
@@ -43,7 +43,7 @@
thumbnailWidthHydrated = true; thumbnailWidthHydrated = true;
const queuedFiles = takeQueuedZipFiles(); const queuedFiles = takePendingFiles();
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles); if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
}); });
@@ -201,6 +201,6 @@
{/snippet} {/snippet}
<div> <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()} {@render content()}
</div> </div>
+42 -9
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte'; import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte'; import Button from '$lib/components/ui/button.svelte';
import { goto } from '$app/navigation';
import { downloadBlob } from '$lib/download'; import { downloadBlob } from '$lib/download';
import { queuePendingFiles } from '$lib/pending-files';
import { mergePdfFiles } from '$lib/pdf-processing'; import { mergePdfFiles } from '$lib/pdf-processing';
import { Trash2 } from '@lucide/svelte'; import { Trash2 } from '@lucide/svelte';
@@ -41,12 +43,34 @@
isProcessing = false; isProcessing = false;
} }
}; };
const mergeAndPaginate = async () => {
if (pdfFiles.length < 2) return;
isProcessing = true;
error = null;
try {
const bytes = await mergePdfFiles(pdfFiles);
const merged = new Uint8Array(bytes.byteLength);
merged.set(bytes);
queuePendingFiles([
new File([merged.buffer], 'zusammengefuegt.pdf', { type: 'application/pdf' })
]);
await goto('/tools/pagination');
} catch (err) {
console.error('Merge for pagination failed:', err);
error = 'Fehler beim Zusammenfügen der PDFs.';
} finally {
isProcessing = false;
}
};
</script> </script>
<div class="mx-auto max-w-4xl space-y-6"> <div class="mx-auto max-w-4xl space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1> <h1 class="text-primary-900 text-2xl font-bold">PDFs zusammenfügen</h1>
<p class="mt-2 text-sm text-primary-700"> <p class="text-primary-700 mt-2 text-sm">
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen. Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
</p> </p>
</div> </div>
@@ -55,13 +79,22 @@
{#if pdfFiles.length > 0} {#if pdfFiles.length > 0}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-primary-900"> <h2 class="text-primary-900 text-lg font-semibold">
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt {pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
</h2> </h2>
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}> <div class="flex flex-wrap items-center justify-end gap-2">
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'} <Button
</Button> variant="tertiary"
onclick={mergeAndPaginate}
disabled={isProcessing || pdfFiles.length < 2}
>
Zusammenfügen &amp; paginieren
</Button>
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
</Button>
</div>
</div> </div>
{#if error} {#if error}
@@ -72,13 +105,13 @@
<div class="space-y-2"> <div class="space-y-2">
{#each pdfFiles as file, index} {#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 <span
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary" class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
> >
{index + 1} {index + 1}
</span> </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"> <div class="flex gap-1">
<Button <Button
variant="tertiary" variant="tertiary"
+446
View File
@@ -0,0 +1,446 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { ChevronLeft, ChevronRight, ListOrdered } from '@lucide/svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { isEncryptedPdfError, pdfLoadErrorMessage } from '$lib/pdf-errors';
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
import {
MAX_FONT_SIZE,
MAX_START_NUMBER,
MIN_FONT_SIZE,
MIN_START_NUMBER,
PAGINATION_FORMATS,
buildPaginationLabel,
normalizeStartNumber,
paginatePdf,
type PaginationFormat,
type PaginationTextScope
} from '$lib/pdf-pagination';
import {
PDF_PREVIEW_SCALE,
loadPdfPageRenderer,
type PdfPagePreview,
type PdfPageRenderer
} from '$lib/pdf-thumbnails';
import { takePendingFiles } from '$lib/pending-files';
const PAGE_NUMBER_TEXT_COLOR = '#1a1a1a';
let file = $state<File | null>(null);
let sourceBytes = $state<Uint8Array | null>(null);
let renderer = $state<PdfPageRenderer | null>(null);
let preview = $state<PdfPagePreview | null>(null);
let currentPage = $state(1);
let isLoading = $state(false);
let isProcessing = $state(false);
let error = $state<string | null>(null);
let startNumber = $state(1);
let freeText = $state('');
let textScope = $state<PaginationTextScope>('all');
let numberFormat = $state<PaginationFormat>('blatt');
let position = $state<OverlayPosition>('bottom-center');
let fontSize = $state(11);
let loadGeneration = 0;
const pageCount = $derived(renderer?.pageCount ?? 0);
const labelOptions = $derived({
startNumber: normalizeStartNumber(startNumber),
freeText,
textScope,
format: numberFormat
});
const previewLabel = $derived(buildPaginationLabel(currentPage - 1, labelOptions));
const exampleLabel = $derived(buildPaginationLabel(0, labelOptions));
const previewFontSize = $derived(
preview ? ((fontSize * PDF_PREVIEW_SCALE) / preview.width) * 100 : 0
);
const canPaginate = $derived(sourceBytes !== null && !isProcessing && !isLoading);
const freeTextHint = $derived(
textScope === 'all'
? 'Der Zusatztext erscheint auf jeder Seite.'
: 'Der Zusatztext erscheint nur auf der ersten Seite.'
);
const showPage = (pageNumber: number) => {
if (!renderer) return;
currentPage = Math.min(Math.max(1, Math.round(pageNumber || 1)), renderer.pageCount);
};
const handlePageJump = (event: Event) => {
showPage(Number((event.currentTarget as HTMLInputElement).value));
};
const syncPageField = (event: Event) => {
(event.currentTarget as HTMLInputElement).value = String(currentPage);
};
const handleStartNumberInput = (event: Event) => {
startNumber = normalizeStartNumber(Number((event.currentTarget as HTMLInputElement).value));
};
const syncStartNumberField = (event: Event) => {
(event.currentTarget as HTMLInputElement).value = String(startNumber);
};
const loadFile = async (nextFile: File) => {
const generation = ++loadGeneration;
renderer?.destroy();
renderer = null;
preview = null;
file = nextFile;
sourceBytes = null;
currentPage = 1;
error = null;
isLoading = true;
try {
const bytes = new Uint8Array(await nextFile.arrayBuffer());
const pageRenderer = await loadPdfPageRenderer(bytes);
if (generation !== loadGeneration) {
pageRenderer.destroy();
return;
}
sourceBytes = bytes;
renderer = pageRenderer;
} catch (loadError) {
console.error('Failed to load PDF for pagination:', loadError);
if (generation === loadGeneration) {
file = null;
error = pdfLoadErrorMessage(loadError);
}
} finally {
if (generation === loadGeneration) isLoading = false;
}
};
const handleFilesSelected = (files: File[]) => {
const [nextFile] = files;
if (nextFile) void loadFile(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: normalizeStartNumber(startNumber),
freeText,
textScope,
format: numberFormat,
position,
fontSize
});
downloadBlob(output, `${currentFile.name.replace(/\.pdf$/i, '')}_paginiert.pdf`);
} catch (processingError) {
console.error('Pagination failed:', processingError);
error = isEncryptedPdfError(processingError)
? pdfLoadErrorMessage(processingError)
: processingError instanceof Error && processingError.message.startsWith('Der Text')
? processingError.message
: 'Das PDF konnte nicht paginiert werden. Prüfen Sie die Datei.';
} finally {
isProcessing = false;
}
};
$effect(() => {
const activeRenderer = renderer;
const pageNumber = currentPage;
if (!activeRenderer) return;
let cancelled = false;
void activeRenderer
.render(pageNumber)
.then((result) => {
if (!cancelled) preview = result;
})
.catch((renderError) => {
if (cancelled) return;
console.error('Failed to render pagination preview:', renderError);
preview = null;
});
return () => {
cancelled = true;
};
});
onMount(() => {
const [pending] = takePendingFiles().filter(
(candidate) => candidate.type === 'application/pdf' || /\.pdf$/i.test(candidate.name)
);
if (pending) void loadFile(pending);
});
onDestroy(() => {
loadGeneration += 1;
renderer?.destroy();
});
</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={isLoading
? 'PDF wird geladen …'
: file
? `${file.name}${pageCount > 0 ? ` · ${pageCount} Seite${pageCount === 1 ? '' : 'n'}` : ''}`
: 'PDF hier ablegen oder auswählen'}
/>
{#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.
</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"
min={MIN_START_NUMBER}
max={MAX_START_NUMBER}
value={startNumber}
oninput={handleStartNumberInput}
onblur={syncStartNumberField}
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="60"
placeholder="z. B. 123 Ja 60/26"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
/>
<p class="text-xs text-muted-foreground">
Höchstens 60 Zeichen, 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}
<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"
>
<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>
</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}
class="sm:col-span-2"
/>
</div>
<p
class="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-foreground"
data-testid="pagination-example"
>
Beispiel: {exampleLabel}
</p>
</section>
<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 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%] overflow-hidden px-1 leading-tight whitespace-nowrap"
style="{getPreviewPositionStyle(
position
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_TEXT_COLOR};"
data-testid="pagination-preview"
>
{previewLabel}
</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 pageCount > 1}
<div class="flex flex-wrap items-center justify-center gap-3">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => showPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => showPage(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"
min="1"
max={pageCount}
value={currentPage}
oninput={handlePageJump}
onblur={syncPageField}
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>
</div>
{/if}
<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={paginate} disabled={!canPaginate}>
{isProcessing ? 'Wird paginiert …' : 'Paginieren & herunterladen'}
</Button>
</div>
</div>
+278 -282
View File
@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte'; import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
import { degrees, PDFDocument, StandardFonts, type PDFFont } from 'pdf-lib'; import { degrees, PDFDocument, StandardFonts } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte'; import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte'; import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import Button from '$lib/components/ui/button.svelte'; import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download'; import { downloadBlob } from '$lib/download';
import { getPositionCoords, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay'; import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { getPreviewPositionStyle, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
import { drawTextOverlay, getRotatedBoxPlacement } from '$lib/pdf-text-overlay';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails'; import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const; const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
@@ -24,7 +28,7 @@
height: number; height: number;
}; };
let pdfFile: File | null = $state(null); let items = $state<BatchItem[]>([]);
let pdfHandle: PdfDocumentHandle | null = $state(null); let pdfHandle: PdfDocumentHandle | null = $state(null);
let stampInput = $state('BEGLAUBIGT'); let stampInput = $state('BEGLAUBIGT');
let withDate = $state(false); let withDate = $state(false);
@@ -40,7 +44,7 @@
let selectedPages = $state(new Set<number>()); let selectedPages = $state(new Set<number>());
let currentPage = $state(1); let currentPage = $state(1);
let isLoading = $state(false); let isLoading = $state(false);
let isProcessing = $state(false); let isRunning = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let handleGeneration = 0; let handleGeneration = 0;
@@ -58,53 +62,53 @@
const selectedPreset = $derived( const selectedPreset = $derived(
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE')) PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
); );
const canProcess = $derived( const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
pdfFile !== null && const stampReady = $derived(stampImage !== null || previewText.length > 0);
pdfHandle !== null && const pageSelectionMissing = $derived(
(stampImage !== null || previewText.length > 0) && items.length === 1 && pdfHandle !== null && !applyToAll && selectedPages.size === 0
!isLoading && );
!isLoadingImage && const canRun = $derived(
!isProcessing && !isRunning && pendingCount > 0 && stampReady && !isLoadingImage && !pageSelectionMissing
(applyToAll || selectedPages.size > 0)
); );
const previewFontSize = $derived( const previewFontSize = $derived(
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62))) Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
); );
const isEncryptedPdfError = (loadError: unknown) => { const handleFilesSelected = async (files: File[]) => {
const message = const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError); const additions = files
return /password|encrypted|encryption|verschl/i.test(message); .filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
}; .map((file) => createBatchItem(file));
if (additions.length === 0) return;
const loadErrorMessage = (loadError: unknown) => items = [...items, ...additions];
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;
selectedPages = new Set(); selectedPages = new Set();
currentPage = 1; currentPage = 1;
error = null; 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 generation = ++handleGeneration;
if (items.length !== 1) {
isLoading = false;
return;
}
const [item] = items;
isLoading = true;
try { try {
const handle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer())); const handle = await loadPdfDocument(new Uint8Array(await item.file.arrayBuffer()));
if (generation !== handleGeneration) { if (generation !== handleGeneration || items.length !== 1) {
handle.destroy(); handle.destroy();
return; return;
} }
pdfHandle = handle; pdfHandle = handle;
} catch (loadError) { } catch (loadError) {
console.error('Failed to load PDF for stamping:', loadError); console.error('Failed to load PDF for stamping:', loadError);
if (generation === handleGeneration) error = loadErrorMessage(loadError); if (generation === handleGeneration) error = pdfLoadErrorMessage(loadError);
} finally { } finally {
if (generation === handleGeneration) isLoading = false; if (generation === handleGeneration) isLoading = false;
} }
@@ -189,108 +193,16 @@
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1); 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 = ( const getImageStampBox = (
image: { width: number; height: number }, image: { width: number; height: number },
pageWidth: number, pageWidth: number,
pageHeight: number pageHeight: number,
requestedSize: number,
withFrame: boolean
) => { ) => {
const padding = withBorder ? PADDING : 0; const padding = withFrame ? PADDING : 0;
const aspectRatio = image.width / image.height; const aspectRatio = image.width / image.height;
let drawHeight = Number(imageSize); let drawHeight = requestedSize;
let drawWidth = drawHeight * aspectRatio; let drawWidth = drawHeight * aspectRatio;
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2; const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
@@ -314,46 +226,50 @@
}; };
}; };
const applyStamp = async () => { const processFile = async (file: File) => {
if (!pdfFile || !pdfHandle || !canProcess) return; // 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 numericFontSize = Number(fontSize);
const numericImageSize = Number(imageSize);
const withFrame = withBorder;
const angle = Number(rotation);
const stampPosition = position;
const color = parseHexColor(stampColor);
const currentStampImage = stampImage;
isProcessing = true; const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
error = null; const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const embeddedImage = currentStampImage
? currentStampImage.mimeType === 'image/png'
? await pdfDoc.embedPng(currentStampImage.bytes)
: await pdfDoc.embedJpg(currentStampImage.bytes)
: null;
const pages = pdfDoc.getPages();
const useAllPages = items.length > 1 || applyToAll;
const pageIndexes = useAllPages
? pages.map((_, index) => index)
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
try { for (const pageIndex of pageIndexes) {
const stampText = buildStampText(stampInput, withDate, new Date()); const page = pages[pageIndex];
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer())); if (!page) continue;
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const embeddedImage = stampImage
? stampImage.mimeType === 'image/png'
? await pdfDoc.embedPng(stampImage.bytes)
: await pdfDoc.embedJpg(stampImage.bytes)
: null;
const pages = pdfDoc.getPages();
const pageIndexes = applyToAll
? 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;
if (embeddedImage) {
const { width, height } = page.getSize(); const { width, height } = page.getSize();
const angle = Number(rotation); const box = getImageStampBox(embeddedImage, width, height, numericImageSize, withFrame);
const box = embeddedImage const placement = getRotatedBoxPlacement(
? getImageStampBox(embeddedImage, width, height)
: fitStampToPage(font, stampText, Number(fontSize), width, height);
const placement = getRotatedStampPlacement(
width, width,
height, height,
box.boxWidth, box.boxWidth,
box.boxHeight, box.boxHeight,
position, stampPosition,
angle angle,
PAGE_MARGIN
); );
if (embeddedImage ? withBorder : true) { if (withFrame) {
page.drawRectangle({ page.drawRectangle({
x: placement.x, x: placement.x,
y: placement.y, y: placement.y,
@@ -365,44 +281,94 @@
}); });
} }
if (embeddedImage) { page.drawImage(embeddedImage, {
const imageBox = box as { drawWidth: number; drawHeight: number; padding: number }; x: placement.x + box.padding * placement.cosine - box.padding * placement.sine,
const offsetX = imageBox.padding; y: placement.y + box.padding * placement.sine + box.padding * placement.cosine,
const offsetY = imageBox.padding; width: box.drawWidth,
page.drawImage(embeddedImage, { height: box.drawHeight,
x: placement.x + offsetX * placement.cosine - offsetY * placement.sine, rotate: degrees(angle)
y: placement.y + offsetX * placement.sine + offsetY * placement.cosine, });
width: imageBox.drawWidth, continue;
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 resultBytes = await pdfDoc.save(); drawTextOverlay(page, {
const baseName = pdfFile.name.replace(/\.pdf$/i, ''); font,
downloadBlob(resultBytes, `${baseName}_stempel.pdf`); text: stampText,
} catch (processingError) { size: numericFontSize,
console.error('Failed to stamp PDF:', processingError); color,
error = isEncryptedPdfError(processingError) position: stampPosition,
? loadErrorMessage(processingError) margin: PAGE_MARGIN,
: processingError instanceof Error && processingError.message.startsWith('Der Stempeltext') padding: PADDING,
? processingError.message framed: true,
: 'Der Stempel konnte nicht angewendet werden. Prüfen Sie die PDF-Datei.'; rotation: angle,
} finally { minSize: MIN_EXPORT_FONT_SIZE,
isProcessing = false; 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.'
);
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;
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 {
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) {
handleGeneration += 1;
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(() => { onDestroy(() => {
@@ -429,15 +395,20 @@
</div> </div>
<FileDropzone <FileDropzone
onFilesSelected={handleFileSelected} onFilesSelected={handleFilesSelected}
label={pdfHandle ? 'Anderes PDF ablegen' : 'PDF zum Stempeln hinzufügen'} multiple
label={items.length === 0 ? 'PDFs zum Stempeln hinzufügen' : 'Weitere PDFs hinzufügen'}
sublabel={isLoading sublabel={isLoading
? 'Vorschau wird geladen …' ? '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} {#if items.length > 0}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]"> <div class="grid gap-6 {pdfHandle ? 'lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]' : ''}">
<section <section
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5" class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
aria-labelledby="stamp-options-title" aria-labelledby="stamp-options-title"
@@ -447,7 +418,8 @@
Stempel einstellen Stempel einstellen
</h2> </h2>
<p class="mt-1 text-xs text-muted-foreground"> <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> </p>
</div> </div>
@@ -590,92 +562,108 @@
</div> </div>
</div> </div>
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages"> {#if items.length > 1}
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} /> <p class="text-sm text-primary" role="note">
Auf alle Seiten anwenden Der Stempel wird auf alle Seiten aller Dateien angewendet.
</label> </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> </section>
<section {#if pdfHandle}
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5" <section
aria-labelledby="stamp-preview-title" 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> <div class="flex items-center justify-between gap-3">
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2> <div>
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p> <h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">
</div> Vorschau
<span class="text-xs font-semibold text-primary" </h2>
>{currentPage} / {pdfHandle.pageCount}</span <p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
>
</div>
{#if pdfHandle.thumbnails[currentPage - 1]}
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
<div
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
>
<img
src={currentThumbnail.imageUrl}
alt="Vorschau von Seite {currentPage}"
class="block w-full"
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
/>
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
{#if stampImage}
<div
data-testid="stamp-preview"
class={withBorder ? 'border-2' : ''}
style="border-color: {stampColor}; padding: {withBorder
? '2px'
: '0'}; transform: rotate({rotation}deg);"
>
<img
src={stampImage.objectUrl}
alt="Stempelbild"
class="block"
style="width: {imageSize * 0.5}px; max-width: 100%;"
/>
</div>
{:else}
<div
data-testid="stamp-preview"
class="border-2 px-2 py-1 font-sans font-extrabold leading-tight whitespace-nowrap"
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
>
{previewText}
</div>
{/if}
</div> </div>
<span class="text-xs font-semibold text-primary"
>{currentPage} / {pdfHandle.pageCount}</span
>
</div> </div>
{/if}
{#if pdfHandle.pageCount > 1} {#if pdfHandle.thumbnails[currentPage - 1]}
<div class="flex items-center justify-center gap-3"> {@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
<Button <div
variant="tertiary" class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
class="min-w-0 px-3"
onclick={showPreviousPage}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
> >
<ChevronLeft aria-hidden="true" /> <img
</Button> src={currentThumbnail.imageUrl}
<Button alt="Vorschau von Seite {currentPage}"
variant="tertiary" class="block w-full"
class="min-w-0 px-3" style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
onclick={showNextPage} />
disabled={currentPage === pdfHandle.pageCount} <div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
aria-label="Nächste Seite" {#if stampImage}
> <div
<ChevronRight aria-hidden="true" /> data-testid="stamp-preview"
</Button> class={withBorder ? 'border-2' : ''}
</div> style="border-color: {stampColor}; padding: {withBorder
{/if} ? '2px'
</section> : '0'}; transform: rotate({rotation}deg);"
>
<img
src={stampImage.objectUrl}
alt="Stempelbild"
class="block"
style="width: {imageSize * 0.5}px; max-width: 100%;"
/>
</div>
{:else}
<div
data-testid="stamp-preview"
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}
</div>
{/if}
</div>
</div>
{/if}
{#if pdfHandle.pageCount > 1}
<div class="flex items-center justify-center gap-3">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={showPreviousPage}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={showNextPage}
disabled={currentPage === pdfHandle.pageCount}
aria-label="Nächste Seite"
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
{/if}
</section>
{/if}
</div> </div>
{#if !applyToAll} {#if pdfHandle && !applyToAll}
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title"> <section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
<div> <div>
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground"> <h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
@@ -689,6 +677,14 @@
</section> </section>
{/if} {/if}
<BatchFileList
{items}
zipBaseName="stempel"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
<div class="flex flex-col items-end gap-3"> <div class="flex flex-col items-end gap-3">
{#if error} {#if error}
<p <p
@@ -698,8 +694,8 @@
{error} {error}
</p> </p>
{/if} {/if}
<Button size="large" onclick={applyStamp} disabled={!canProcess}> <Button size="large" onclick={startBatch} disabled={!canRun}>
{isProcessing ? 'Stempel wird angewendet …' : 'Stempel anwenden & herunterladen'} {isRunning ? 'Stempel wird angewendet …' : 'Stempel anwenden'}
</Button> </Button>
</div> </div>
{:else if error} {:else if error}
+21
View File
@@ -123,6 +123,27 @@ export const getPageContentText = (document: PDFDocument, pageIndex: number) =>
.join('\n'); .join('\n');
}; };
/**
* Decoded operands of all `Tj` operators. pdf-lib writes drawn text as hex
* strings (`<426C2E2031> Tj`), so the raw content stream is not readable.
*/
export const getPageTextValues = (document: PDFDocument, pageIndex: number) => {
const content = getPageContentText(document, pageIndex);
const values: string[] = [];
for (const match of content.matchAll(/<([0-9a-fA-F]+)>\s*Tj/g)) {
values.push(Buffer.from(match[1], 'hex').toString('latin1'));
}
for (const match of content.matchAll(/\(((?:\\.|[^()\\])*)\)\s*Tj/g)) {
values.push(match[1]);
}
return values;
};
export const getPageText = (document: PDFDocument, pageIndex: number) =>
getPageTextValues(document, pageIndex).join('\n');
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => { export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label)); const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
document.encrypt({ document.encrypt({
+176
View File
@@ -0,0 +1,176 @@
import { expect, test, type Page } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import {
createEncryptedPdf,
createPdf,
createPreRotatedPdf,
dropFiles,
expectPdfDownload,
getPageContentText,
getPageText,
pdfDropzone,
trackPageErrors
} from './helpers';
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);
expect(getPageText(output, 0)).toContain('123 Ja 60/26, Bl. 26');
expect(getPageText(output, 1)).toContain('123 Ja 60/26, Bl. 27');
expect(getPageText(output, 2)).toContain('123 Ja 60/26, Bl. 28');
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);
// Page 1 is stored with /Rotate 90: the text is counter-rotated so it reads
// horizontally on screen instead of sideways.
const rotatedContent = getPageContentText(output, 0);
expect(getPageText(output, 0)).toContain('Bl. 1');
const rotatedMatrix =
/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) Tm\n<426C2E2031> Tj/.exec(
rotatedContent
);
expect(rotatedMatrix).not.toBeNull();
const [a, b, c, d, x, y] = rotatedMatrix!.slice(1).map(Number);
// 90° counter-clockwise in user space cancels the viewer's page rotation.
expect(Math.abs(a)).toBeLessThan(0.001);
expect(b).toBeCloseTo(1, 3);
expect(c).toBeCloseTo(-1, 3);
expect(Math.abs(d)).toBeLessThan(0.001);
// Visual bottom center of the 594×420 display: 28 pt margin, centered.
expect(x).toBeGreaterThan(384);
expect(x).toBeLessThan(396);
expect(y).toBeGreaterThan(280);
expect(y).toBeLessThan(292);
const plainContent = getPageContentText(output, 1);
expect(getPageText(output, 1)).toContain('Bl. 2');
expect(plainContent).toMatch(/1 0 0 1 [-\d.]+ [-\d.]+ Tm\n<426C2E2032> Tj/);
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 { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { import {
createEncryptedPdf, 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')).toContainText('EILT');
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none'); 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'); 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 outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes); const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3); 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('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
await page.getByRole('button', { name: /Seite 2/ }).click(); 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'); 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 outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes); const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3); 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' }) page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
).not.toBeChecked(); ).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'); 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 outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes); const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(2); 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 page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid'); 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'); 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( const borderedBytes = await expectPdfDownload(
await borderedDownloadPromise, await borderedDownloadPromise,
/^bildakte_stempel\.pdf$/ /^bildakte_stempel\.pdf$/
@@ -120,6 +138,45 @@ test('stamp uses an image instead of text and makes the border optional', async
expect(pageErrors).toEqual([]); 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 }) => { test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page); const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp'); 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 page.goto('/');
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible(); 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: /Stempeln/ })).toBeVisible();
await expect(page.getByRole('link', { name: /Paginieren/ })).toBeVisible();
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0); await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
await dropFiles(page, 'window', [ await dropFiles(page, 'window', [