Files
bea-edit/plan/done/page-numbers-plan.md
T
2026-10-28 09:25:15 +01:00

251 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.