251 lines
12 KiB
Markdown
251 lines
12 KiB
Markdown
# 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.
|