From b5279434b2b80b47b630c9029aae250f7ee19997 Mon Sep 17 00:00:00 2001 From: rehlert Date: Wed, 28 Oct 2026 09:25:15 +0100 Subject: [PATCH] implements page-numbers-plan --- agents.md | 43 +- plan/done/page-numbers-plan.md | 250 ++++++++++ src/lib/batch.ts | 10 +- src/lib/pdf-errors.ts | 26 ++ src/lib/pdf-overlay.ts | 23 + src/lib/pdf-pagination.ts | 81 ++++ src/lib/pdf-text-overlay.ts | 248 ++++++++++ src/lib/pdf-thumbnails.ts | 109 ++++- src/lib/pending-files.ts | 17 +- src/lib/tools.ts | 7 + src/routes/+page.svelte | 22 +- src/routes/tools/bea/+page.svelte | 6 +- src/routes/tools/merge/+page.svelte | 51 ++- src/routes/tools/pagination/+page.svelte | 446 ++++++++++++++++++ src/routes/tools/stamp/+page.svelte | 560 +++++++++++------------ tests/e2e/helpers.ts | 21 + tests/e2e/pagination.spec.ts | 176 +++++++ tests/e2e/stamp.spec.ts | 65 ++- tests/e2e/start-page.spec.ts | 1 + 19 files changed, 1823 insertions(+), 339 deletions(-) create mode 100644 plan/done/page-numbers-plan.md create mode 100644 src/lib/pdf-errors.ts create mode 100644 src/lib/pdf-pagination.ts create mode 100644 src/lib/pdf-text-overlay.ts create mode 100644 src/routes/tools/pagination/+page.svelte create mode 100644 tests/e2e/pagination.spec.ts diff --git a/agents.md b/agents.md index 64ed33b..04eb288 100644 --- a/agents.md +++ b/agents.md @@ -90,10 +90,13 @@ src/ │ │ └── zip-inflating.service.ts # Async fflate wrapper │ ├── batch.ts # Sequential batch engine for multi-file tools │ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline +│ ├── pdf-errors.ts # Shared encrypted-PDF detection and German messages │ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only) -│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing +│ ├── pdf-overlay.ts # Shared overlay positions, preview CSS and color parsing +│ ├── pdf-pagination.ts # Sheet-number labels and the pagination pipeline │ ├── pdf-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 │ └── zip-processing.ts # Archive model, transforms, and PDF export └── routes/ @@ -104,7 +107,7 @@ src/ ├── tools/ │ ├── +layout.svelte # Shared back-navigation shell │ ├── bea/+page.svelte # Existing ZIP archive workflow - │ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/ + │ └── {pagination,merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/ ├── datenschutz/+page.svelte # Privacy policy └── impressum/+page.svelte # Imprint static/ # Public logo, footer art, PWA icons, and robots.txt @@ -146,7 +149,13 @@ committed. ## Application flow and state - `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and - forwarded to `/tools/bea`; the file is not uploaded or persisted. + forwarded to `/tools/bea`; the file is not uploaded or persisted. The same `queuePendingFiles` / + `takePendingFiles` handoff carries the merged PDF from `/tools/merge` to `/tools/pagination`. +- `/tools/pagination` is a single-PDF tool: it renders one page at a time through + `loadPdfPageRenderer` (never the all-pages thumbnail array), composes every label with + `buildPaginationLabel` — the single source for both the live preview and the export — and stamps + all pages through `drawTextOverlay`. The page number is always on every page; the optional free + text (Aktenzeichen) follows its own “every page” / “first page only” scope. - `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }` result for password-protected files, and detects embedded-file markers by scanning raw bytes. @@ -231,6 +240,13 @@ All case-file processing is intentionally local to the browser. - Page-set copies (separate/extract tools, PDF splitting) share `copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy semantics cannot drift apart. +- `src/lib/pdf-text-overlay.ts` owns text fitting, the rotated-box placement and the optional + `/Rotate` compensation. Stamp keeps `compensatePageRotation` off so its output does not change; + pagination turns it on so numbers stay at the visual position on rotated scans. 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 rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity safeguards. @@ -243,15 +259,15 @@ called in the browser. ## Batch processing pattern for single-PDF tools -Tools that transform one PDF per run (compress, watermark, encrypt, …) are being retrofitted to -accept multiple files. Follow the established compress retrofit when adding the pattern to another -tool; see `plan/batch-processing-plan.md` for the full design. +Tools that transform one PDF per run (compress, watermark, stamp, encrypt, decrypt) accept multiple +files. Follow the established compress retrofit when adding the pattern to another tool; see +`plan/done/batch-processing-plan.md` for the full design. - `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update, options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PDF in flight for memory safety), never throws — per-item failures are caught and written to the item as a German - error message — and consults the optional `shouldProcess` predicate so rows removed mid-run are - skipped. + error message (a string, or a formatter that can inspect the thrown error) — and consults the + optional `shouldProcess` predicate so rows removed mid-run are skipped. - `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip (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 @@ -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 so option changes mid-run affect only subsequent files, and gate the run button on pending items. Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead - of blocking the batch. -- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, encrypt - `geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the batch - flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal). + of blocking the batch. Keep thumbnails, previews, and page-specific selection to a single dropped + file; multi-file runs apply the tool to every page of every file. +- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, stamp `stempel`, + 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 diff --git a/plan/done/page-numbers-plan.md b/plan/done/page-numbers-plan.md new file mode 100644 index 0000000..ce24ce0 --- /dev/null +++ b/plan/done/page-numbers-plan.md @@ -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. diff --git a/src/lib/batch.ts b/src/lib/batch.ts index 3a98c85..498af78 100644 --- a/src/lib/batch.ts +++ b/src/lib/batch.ts @@ -12,8 +12,11 @@ export type BatchItem = { export type BatchProcessResult = { bytes: Uint8Array; name: string }; export type BatchRunOptions = { - /** Error message written to a row when `process` throws for it. */ - errorMessage?: string; + /** + * Error message written to a row when `process` throws for it. A function + * receives the thrown error so tools can surface a specific German message. + */ + errorMessage?: string | ((error: unknown, file: File) => string); /** * Optional predicate consulted before each item; when it returns `false` * (for example because the row was removed mid-run), the item is skipped. @@ -58,7 +61,8 @@ export const runBatch = async ( status: 'error', resultBytes: undefined, resultName: undefined, - errorMessage + errorMessage: + typeof errorMessage === 'function' ? errorMessage(error, item.file) : errorMessage }); } } diff --git a/src/lib/pdf-errors.ts b/src/lib/pdf-errors.ts new file mode 100644 index 0000000..af11fd6 --- /dev/null +++ b/src/lib/pdf-errors.ts @@ -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; +}; diff --git a/src/lib/pdf-overlay.ts b/src/lib/pdf-overlay.ts index 70e16c1..cfc9019 100644 --- a/src/lib/pdf-overlay.ts +++ b/src/lib/pdf-overlay.ts @@ -50,3 +50,26 @@ export const parseHexColor = (hexColor: string): RGB => { 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%;'; + } +}; diff --git a/src/lib/pdf-pagination.ts b/src/lib/pdf-pagination.ts new file mode 100644 index 0000000..4126a8f --- /dev/null +++ b/src/lib/pdf-pagination.ts @@ -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 +): 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 => { + 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(); +}; diff --git a/src/lib/pdf-text-overlay.ts b/src/lib/pdf-text-overlay.ts new file mode 100644 index 0000000..85fdc74 --- /dev/null +++ b/src/lib/pdf-text-overlay.ts @@ -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) + }); +}; diff --git a/src/lib/pdf-thumbnails.ts b/src/lib/pdf-thumbnails.ts index b020f79..8884838 100644 --- a/src/lib/pdf-thumbnails.ts +++ b/src/lib/pdf-thumbnails.ts @@ -11,7 +11,27 @@ export type PdfDocumentHandle = { destroy: () => void; }; -export async function loadPdfDocument(data: Uint8Array): Promise { +/** 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; + destroy: () => void; +}; + +const loadPdfJs = async () => { const pdfjsLib = await import('pdfjs-dist'); const { getDocument, GlobalWorkerOptions } = pdfjsLib; @@ -19,6 +39,20 @@ export async function loadPdfDocument(data: Uint8Array): Promise module.default ); + return getDocument; +}; + +const canvasToPngBlob = (canvas: HTMLCanvasElement) => + new Promise((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 { + const getDocument = await loadPdfJs(); + const loadingTask = getDocument({ data: new Uint8Array(data) }); const thumbnails: PdfThumbnail[] = []; @@ -36,17 +70,10 @@ export async function loadPdfDocument(data: Uint8Array): Promise((resolve, reject) => { - canvas.toBlob( - (result) => - result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')), - 'image/png' - ); - }); thumbnails.push({ pageNumber, - imageUrl: URL.createObjectURL(blob), + imageUrl: URL.createObjectURL(await canvasToPngBlob(canvas)), width: viewport.width, height: viewport.height }); @@ -69,3 +96,67 @@ export async function loadPdfDocument(data: Uint8Array): Promise { + 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(); + } + }; +} diff --git a/src/lib/pending-files.ts b/src/lib/pending-files.ts index 6d8c0c0..b49f4ae 100644 --- a/src/lib/pending-files.ts +++ b/src/lib/pending-files.ts @@ -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 = () => { - const files = pendingZipFiles; - pendingZipFiles = []; +export const takePendingFiles = () => { + const files = pendingFiles; + pendingFiles = []; return files; }; diff --git a/src/lib/tools.ts b/src/lib/tools.ts index ac3fd9d..99fdb53 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -12,6 +12,13 @@ export const tools: Tool[] = [ description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.', icon: 'stamp' }, + { + slug: 'pagination', + title: 'Paginieren', + description: + 'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.', + icon: 'list-ordered' + }, { slug: 'watermark', title: 'Wasserzeichen', diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5e2d6fe..f110618 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,6 +9,7 @@ Scissors, Merge, Stamp, + ListOrdered, RotateCw, Info, FileText @@ -16,7 +17,7 @@ import { goto } from '$app/navigation'; import FileDropzone from '$lib/components/FileDropzone.svelte'; import * as Card from '$lib/components/ui/card/index'; - import { queueZipFiles } from '$lib/pending-files'; + import { queuePendingFiles } from '$lib/pending-files'; import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection'; import { tools } from '$lib/tools'; @@ -29,6 +30,7 @@ scissors: Scissors, merge: Merge, stamp: Stamp, + 'list-ordered': ListOrdered, 'rotate-cw': RotateCw, info: Info }; @@ -37,7 +39,7 @@ const workspaceFiles = files.filter(isAcceptedWorkspaceFile); if (workspaceFiles.length === 0) return; - queueZipFiles(workspaceFiles); + queuePendingFiles(workspaceFiles); void goto('/tools/bea'); }; @@ -62,12 +64,12 @@
-

Werkzeugkasten für beA

-

+

Werkzeugkasten für beA

+

Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus praktische PDF-Werkzeuge.

-

+

Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.

@@ -84,7 +86,7 @@ icon={Archive} class="py-10" /> -

+

Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs können direkt in Teildokumente aufgeteilt werden.

@@ -92,12 +94,12 @@
-

Weitere PDF-Werkzeuge

+

Weitere PDF-Werkzeuge

{#each tools as tool} {@const Icon = iconMap[tool.icon] ?? FileText} - +
-

{tool.title}

-

{tool.description}

+

{tool.title}

+

{tool.description}

diff --git a/src/routes/tools/bea/+page.svelte b/src/routes/tools/bea/+page.svelte index 50e02f6..a437ead 100644 --- a/src/routes/tools/bea/+page.svelte +++ b/src/routes/tools/bea/+page.svelte @@ -14,7 +14,7 @@ } from '$lib/zip-processing'; import { isAcceptedPdfFile } from '$lib/zip-selection'; import { downloadBlob } from '$lib/download'; - import { takeQueuedZipFiles } from '$lib/pending-files'; + import { takePendingFiles } from '$lib/pending-files'; import * as Card from '$lib/components/ui/card/index'; const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth'; @@ -43,7 +43,7 @@ thumbnailWidthHydrated = true; - const queuedFiles = takeQueuedZipFiles(); + const queuedFiles = takePendingFiles(); if (queuedFiles.length > 0) handleFilesSelected(queuedFiles); }); @@ -201,6 +201,6 @@ {/snippet}
-

beA-Edit

+

beA-Edit

{@render content()}
diff --git a/src/routes/tools/merge/+page.svelte b/src/routes/tools/merge/+page.svelte index 43482b3..40f6809 100644 --- a/src/routes/tools/merge/+page.svelte +++ b/src/routes/tools/merge/+page.svelte @@ -1,7 +1,9 @@
-

PDFs zusammenfügen

-

+

PDFs zusammenfügen

+

Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.

@@ -55,13 +79,22 @@ {#if pdfFiles.length > 0}
-
-

+
+

{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt

- +
+ + +
{#if error} @@ -72,13 +105,13 @@
{#each pdfFiles as file, index} -
+
{index + 1} - {file.name} + {file.name}
+ + +
+ {/if} + +
+ {/if} + +
+ {#if error} + + {/if} + +
+
diff --git a/src/routes/tools/stamp/+page.svelte b/src/routes/tools/stamp/+page.svelte index ad313e0..4b2480b 100644 --- a/src/routes/tools/stamp/+page.svelte +++ b/src/routes/tools/stamp/+page.svelte @@ -1,12 +1,16 @@