12 KiB
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 existinggetPositionCoordspresets. - 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;
loadPdfDocumentrasterises every page up front. A newloadPdfPageRenderer(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:
{
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:
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: valueand its bold font; behaviour must stay identical (guard:tests/e2e/stamp.spec.ts). compensatePageRotationmaps bottom-center on a/Rotate 90scan to the correct user-space anchor and counter-rotates the text. Stamp keepsfalseso its output does not change.- Move
isEncryptedPdfError/ the encrypted-PDF message intosrc/lib/pdf-errors.tsso all three tools share one wording.
Pagination
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.Helveticaper 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.tswithloadPdfPageRenderer(bytes): Promise<{ pageCount, render(pageNumber), destroy() }>;renderbuilds the canvas from the requested page's own viewport (mixed page sizes stay correct), revokes the previous object URL, anddestroytears down the pdfjs loading task. Reused by the exhibit tool's page-1 preview.
Merge integration
- Generalise
src/lib/pending-files.ts:queueZipFiles/takeQueuedZipFilesbecomequeuePendingFiles/takePendingFiles(same behaviour; updatesrc/routes/+page.svelteandsrc/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 innew File([bytes], 'zusammengefuegt.pdf', { type: 'application/pdf' }), queues it and callsgoto('/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()inonMount, accepts the first PDF and ignores anything else defensively.
Registry and docs
src/lib/tools.ts+iconMapinsrc/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— addloadPdfPageRenderer.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
- Extract
pdf-text-overlay.ts+pdf-errors.ts, rewire stamp, keepstamp.spec.tsgreen. - Add
loadPdfPageRendererand use it for the preview. - Build the pagination route (options, live preview, export, errors).
- Merge handoff + generic pending files.
- Registry/icon/docs.
- Playwright coverage + command gauntlet.
Verification
- 3-page PDF, start 26, text
123 Ja 60/26, all pages → each page's content stream contains123 Ja 60/26, Bl. 26/27/28; page count and page order preserved; download namedakte_paginiert.pdf. - First-page-only text → page 1 contains the Aktenzeichen, page 2 contains
Bl. 2but 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/paginationwith a 3-page document; running produces continuousBl. 1…3. - Encrypted PDF → German error, no download.
pnpm check, targetedpnpm test:e2e,pnpm build, Prettier on touched files.
Risks
- Long documents: never call
loadPdfDocumenthere; 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.