diff --git a/agents.md b/agents.md index 64ed33b..92dec0d 100644 --- a/agents.md +++ b/agents.md @@ -38,7 +38,7 @@ pnpm build # Create the adapter-node production build pnpm preview # Preview the production build pnpm check # Run Svelte and TypeScript diagnostics pnpm check:watch # Run diagnostics in watch mode -pnpm test:e2e # Run the Playwright toolbox tests in Chromium +pnpm test:e2e # Run the toolbox specs (tests/e2e) and the unit specs (tests/unit) pnpm test:pwa # PWA offline/update tests against the production build (requires pnpm build) pnpm lint # Check repository formatting with Prettier pnpm format # Format the repository with Prettier @@ -74,9 +74,13 @@ src/ │ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry │ │ ├── BeaArchiveProcessing.svelte │ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs +│ │ ├── ExhibitFileList.svelte # Reorderable Anlagen list: labels, renames, results │ │ ├── ProcessedZipArchiveEditor.svelte +│ │ ├── PaginationPreview.svelte # Rendered page preview incl. shrink hints and page nav │ │ ├── PdfSplitDialog.svelte # Page-level PDF splitting into Teildokumente +│ │ ├── SinglePdfTool.svelte # Shell for one-PDF tools (header, dropzone, slots) │ │ ├── SubDocumentEditor.svelte +│ │ ├── ToolError.svelte # Shared error banner (role="alert") │ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target │ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input │ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid @@ -89,11 +93,19 @@ src/ │ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing │ │ └── zip-inflating.service.ts # Async fflate wrapper │ ├── batch.ts # Sequential batch engine for multi-file tools +│ ├── download.ts # Blob/download helpers (pdfBytesToBlob, downloadBlobFile) +│ ├── exhibit-naming.ts # Anlagen label, start number and file-name helpers +│ ├── latest-task.ts # "Only the latest async call wins" tokens │ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline +│ ├── pdf-errors.ts # Shared PDF error classification 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-preview-text.ts # Canvas text measurement for HTML previews │ ├── pdf-processing.ts # Reusable PDF merge helper -│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup +│ ├── pdf-session.svelte.ts # Shared single-PDF lifecycle (file, bytes, handle, errors) +│ ├── pdf-text-overlay.ts # Shared framed/rotated text and image 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,13 +116,15 @@ 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}/ + │ └── {exhibits,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 components.json # shadcn-svelte registry configuration Dockerfile # Node adapter production image playwright.pwa.config.ts # Playwright config for `pnpm test:pwa` +tests/e2e/ # Playwright toolbox specs +tests/unit/ # Pure-function specs (run by the same Playwright config) plan/ # Design and implementation notes ``` @@ -146,7 +160,20 @@ 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/exhibits` is the Anlagen tool and the only batch tool with an order. One dropped PDF is + one Anlage: `src/lib/exhibit-naming.ts` owns the label (`buildExhibitLabel`, no padded numbers in + the stamp) and the optional file name (`buildExhibitFileName`, `{prefixSlug}_{paddedNumber}_…`, + padding from `getExhibitNumberWidth`). Numbers are frozen per row when the run starts + (`assignments` map), so reordering or changing the start number never rewrites a created Anlage — + the list keeps showing the frozen label while pending rows follow the live order. The stamp is + `drawTextOverlay` with `framed: true`, `compensatePageRotation: true`, first page by default. - `/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 +258,35 @@ 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. `drawImageOverlay` + is the image counterpart. Share both instead of re-implementing overlay maths in a route, and take + the margin/padding presets (`TEXT_OVERLAY_MARGIN`, `TEXT_OVERLAY_PADDING`, + `MIN_TEXT_OVERLAY_SIZE`) from the module rather than redefining them locally. +- `src/lib/pdf-errors.ts` classifies PDF failures (`classifyPdfError`, `TextOverlayError`, + `isEncryptedPdfError`) and owns the German copy (`pdfLoadErrorMessage`, `pdfProcessingErrorMessage`, + both with a tool-specific fallback). All tools use it; never match on pdf-lib/pdfjs error text in a + route, and never copy the password hint locally. +- `src/lib/pdf-session.svelte.ts` (`createPdfSession`) is the shared single-PDF lifecycle for rotate, + separate and pagination: it reads the bytes once, builds the tool handle, maps load errors and + invalidates stale loads. Keep new single-PDF tools on it instead of re-declaring + `file`/`bytes`/`handle`/`isLoading`/`loadingError` and a generation counter. +- `src/lib/latest-task.ts` (`createLatestToken`) is the guard for "only the newest async call may + publish its result" (file loads, preview renders). Do not add another hand-rolled counter. +- Markup is shared the same way: `ToolError.svelte` owns the red `role="alert"` banner (never + inline the classes in a route), `SinglePdfTool.svelte` owns the header + dropzone + toolbar + + footer layout for one-PDF tools (rotate, separate — keep new ones on it), and + `PaginationPreview.svelte` owns the page preview with its shrink hints. Tool-specific copy is + passed in as props; keep it out of the shared components. +- `src/lib/pending-files.ts` hands files between routes. Queue with an explicit target + (`queuePendingFiles(files, 'bea' | 'pagination')`) and consume with `takePendingFiles(target)` so a + tool cannot silently swallow a file queued for another one. +- `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 share + `PdfPageImage` and one rendering helper, revoke object URLs and tear down the pdfjs loading task — + including when the initial `getDocument` promise rejects. Superseded renders throw + `PreviewRenderCancelledError`, which callers swallow instead of logging. - `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,16 +299,23 @@ 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 +options?)`, `formatBytes`, `batchStatusLabel`/`batchStatusClass` and + `shouldWarnAboutBatchMemory`. `process` is `(file, item) => Promise<{ bytes, name }>`: the item is + passed so a tool can resolve per-row data frozen at run start (the Anlagen tool resolves its + exhibit number that way; `(file) => …` callbacks stay valid). `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 (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 +- `src/lib/batch.ts` also owns the bulk export: `uniqueEntryName` (duplicate suffixes) and + `downloadBatchZip(items, zipBaseName)` → `{toolname}_{date}.zip`. Keep new batch tools on it + instead of building ZIPs inside a component. +- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip (`batchStatusClass`/`batchStatusLabel`) (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 total result-size summary, a memory warning (>20 files or any file >25 MB), and the bulk @@ -262,10 +325,17 @@ 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. +- `src/lib/components/ExhibitFileList.svelte` is the other batch list, for tools where order and a + derived per-row label matter (currently `/tools/exhibits`): drag handle plus up/down buttons that + both call one `onMove(from, to)`, position badge, the resolved label, the renamed file name, and + the same status/download/retry/remove/ZIP surface as `BatchFileList`. Reordering and preview + selection are disabled while a run is active. +- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, stamp `stempel`, + exhibits `anlagen`, 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/exhibit-numbering-plan.md b/plan/done/exhibit-numbering-plan.md new file mode 100644 index 0000000..1f25d1f --- /dev/null +++ b/plan/done/exhibit-numbering-plan.md @@ -0,0 +1,237 @@ +# Plan: Anlagen-Bezeichnung — Anlagen nummerieren und benennen + +## Goal + +Add a new tool **„Anlagen nummerieren“** (`/tools/exhibits`): users drop any number of PDFs, sort +them, choose an exhibit label („Anlage“, „Anlage K“, „Anlage B“, „Anlage XYZ“ or free text), a start +number and the page scope, and receive each PDF stamped with its exhibit number — optionally renamed +to `Anlage_K_06_Originalname.pdf` for court submission. + +Context: mail thread with Nikolai Klute (26.09.2026 / 06.10.2026). The requirement is explicitly +_one PDF = one Anlage = one number_, with sortable order, configurable label and counter start, and +optional filename prefixing. Per-page counters are the separate pagination plan. + +Related: `plan/done/page-numbers-plan.md` (shared overlay/renderer), `plan/done/stamp-plan.md`, +`plan/done/batch-processing-plan.md`. + +--- + +## Open decisions (defaults marked ✅) + +### D1 — Numbering unit + +- ✅ **A. One uploaded PDF = one Anlage = one number.** An „Anlage K 6“ may have many pages; the + stamp identifies the document, not the sheet. +- B. Group several PDFs into one Anlage. Not requested; deferred (a group would need its own reorder + UI inside a group). + +### D2 — Stamped pages + +- ✅ **A. First page by default,** checkbox „Auf jeder Seite stempeln“ for every page of that + exhibit. +- B. Every page by default. Real exhibit stamps usually mark the first page; the stamp tool already + covers „everywhere“. + +### D3 — Look + +- ✅ **A. Framed exhibit stamp:** `Anlage K 6` in title case, HelveticaBold 14 pt (slider 10–24), + black `#1a1a1a` (color input included, same control as the stamp tool), 2 pt border, 6 pt padding, + no rotation, no date, default position **oben rechts**, 20 pt margin; the six other positions are + selectable. +- B. Reuse the red authority stamp look. Wrong signal: an exhibit label is a designation, not an + endorsement. + +### D4 — Label and presets + +- ✅ **A. Preset chips + editable text.** Chips `Anlage`, `Anlage K`, `Anlage B`, `Anlage XYZ` + (`maxlength="40"`, default `Anlage`). The stamp text is `${prefix} ${number}` with a single space — + no leading zeros in the stamp (`Anlage K 6`, not `Anlage K 06`). +- B. Fixed list only. Firms use their own exhibit letters. + +### D5 — Filename renaming + +- ✅ **A. Optional, default on.** Pattern `{prefixSlug}_{paddedNumber}_{originalBase}.pdf`, e.g. + `Anlage_K_06_Klageschrift.pdf`. Prefix slug: trim, collapse whitespace to `_`, strip + `/\:*?"<>|` and control characters, keep German letters. Padding = `max(2, digits of highest +assigned number)`, so 1…99 → `06`, 1…100 → `006`. + Renaming off keeps the original file name exactly (ZIP duplicate names are still deduplicated by + the batch ZIP helper). +- B. Always rename. Users sometimes want to control file names themselves. + +### D6 — Ordering + +- ✅ **A. Drag-and-drop with a visible handle, plus up/down buttons.** Buttons are the keyboard and + Playwright path; both call the same `moveItem(from, to)`. Reordering is disabled while a run is + active. +- B. Up/down only. Too slow for 30 exhibits. + +### D7 — Number freezing + +- ✅ **A. Numbers are frozen when a run starts** (`itemId → start + index`). Results keep the number + they were stamped with even if the user reorders afterwards; the list shows + „Bereits erstellte Anlagen behalten ihre Nummer.“ Retry uses the frozen number. A clean renumber + means removing results and running again. +- B. Live renumbering after processing. Would silently invalidate downloaded files and produce + duplicate numbers. + +### D8 — Deliverables + +- ✅ **A. Per-row downloads + one ZIP „Alle herunterladen“** via the existing batch ZIP engine + (`anlagen_2026-10-06.zip`). A merged „Anlagenkonvolut“ stays out of scope — „PDFs zusammenfügen“ + exists. +- B. Always one merged PDF. Not requested and destroys per-exhibit files. + +### D9 — Preview + +- ✅ **A. Preview only for the selected row**, page 1 rendered on demand (shared + `loadPdfPageRenderer` from the pagination plan), overlay scaled like the stamp preview; selecting + another row disposes the previous handle. +- B. Thumbnail per row. Renders every dropped PDF completely — memory trap. + +--- + +## UX + +Route `/tools/exhibits`, title „Anlagen nummerieren“, registry entry next to `stamp`: + +```ts +{ + slug: 'exhibits', + title: 'Anlagen nummerieren', + description: 'Nummerieren und benennen Sie Anlagen für die Einreichung — mit Anlagenstempel.', + icon: 'files' +} +``` + +- **Header/description:** „Versehen Sie mehrere PDFs mit fortlaufenden Anlagen-Bezeichnungen und + benennen Sie die Dateien passend um.“ +- **FileDropzone** (multiple) — „PDFs als Anlagen hinzufügen“; appending keeps the current list + (same dedupe by name + size as the stamp tool). +- **Numbered list** (`ExhibitFileList.svelte`, not `BatchFileList` — it needs reordering and number + labels): + - per row: drag handle, order badge, computed label („Anlage K 6“), file name, renamed preview + (`→ Anlage_K_06_Klageschrift.pdf`), status chip, per-row download / retry / remove, and a + „Vorschau“ selection. + - live label updates for pending rows when order, prefix or start number changes; done rows show + the frozen label. +- **Options card** + - Bezeichnung: preset chips + text input (`maxlength="40"`), live example „Anlage K 6“. + - Startnummer (number input, default 1, min 0) + hint „3 Dateien → Anlage K 6, 7, 8“. + - „Auf jeder Seite stempeln“ checkbox (default off). + - Position (default oben rechts), Schriftgröße slider 10–24 (default 14), Farbe (default + `#1a1a1a`). + - „Dateien umbenennen“ checkbox (default on) with the filename pattern as helper text. +- **Preview card:** selected row, page 1, overlay of the row's label at the chosen position; page + count of the selected file shown (requires loading it — the renderer reports both). +- **Primary action** „Anlagen erstellen“ → `runBatch` over all pending rows; then per-row downloads + and the ZIP action. While running: „Datei 3 von 7 wird verarbeitet…“ (batch pattern) and + reordering disabled. +- Errors: encrypted PDFs become per-row error rows with the shared decryption hint; an empty prefix + blocks the run with a German validation hint; too-long labels use the overlay's fit/error path. + +## Technical approach + +### Shared overlay and errors + +- Uses `drawTextOverlay` from `src/lib/pdf-text-overlay.ts` (introduced by + `plan/done/page-numbers-plan.md`) with `framed: true`, bold font, `compensatePageRotation: true`. +- Uses `src/lib/pdf-errors.ts` for the encrypted-PDF message. +- If the exhibit plan ships first, it creates both modules and the pagination plan reuses them. + +### Batch engine + +- Extend `runBatch`'s callback to `(file: File, item: BatchItem) => Promise` so + the tool can resolve the frozen number per row (existing `(file) => …` callbacks remain valid). +- Keep `shouldProcess`, per-row error formatting and the sequential loop unchanged. +- Extract the ZIP/status logic that currently lives inline in `BatchFileList.svelte` into + `src/lib/batch.ts` (`downloadBatchZip(items, zipBaseName)`, `batchStatusLabel`, + `batchStatusClass`) and reuse it from `BatchFileList` and `ExhibitFileList` without behaviour + changes. + +### Naming helper (`src/lib/exhibit-naming.ts`) + +```ts +export const buildExhibitLabel = (prefix: string, number: number) => `${prefix.trim()} ${number}`; + +export const buildExhibitFileName = (options: { + prefix: string; + number: number; + highestNumber: number; + originalName: string; +}): string => { + const base = options.originalName.replace(/\.pdf$/i, ''); + const slug = options.prefix + .trim() + .replace(/\s+/g, '_') + .replace(/[/\\:*?"<>|\u0000-\u001f]/g, ''); + const width = Math.max(2, String(options.highestNumber).length); + return `${slug}_${String(options.number).padStart(width, '0')}_${base}.pdf`; +}; +``` + +### Stamping + +- Load the PDF with pdf-lib, embed `StandardFonts.HelveticaBold`, draw the label on page 0 (or all + pages), save. +- Result name from the naming helper (or the original name when renaming is off). The batch result + keeps the bytes in memory for download/ZIP. +- Numbering is frozen at run start; `process` resolves `numbers.get(item.id) ?? freeze(item)`. + +## Files changed + +- `src/routes/tools/exhibits/+page.svelte` — new route. +- `src/lib/components/ExhibitFileList.svelte` — reorderable list with status/downloads. +- `src/lib/exhibit-naming.ts` — label + filename helpers. +- `src/lib/batch.ts`, `src/lib/components/BatchFileList.svelte` — `runBatch` item context, shared + ZIP/status helpers. +- `src/lib/pdf-text-overlay.ts`, `src/lib/pdf-errors.ts`, `src/lib/pdf-thumbnails.ts` — shared + helpers (whichever plan ships first creates them). +- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map (`Files`). +- `tests/e2e/exhibits.spec.ts` — new spec. +- `agents.md` — route, batch and shared-helper documentation. + +## Implementation phases + +1. Shared helpers (`pdf-text-overlay`, `pdf-errors`, page renderer) + stamp/compress regression. +2. Batch refactor: `runBatch` item context, ZIP/status extraction, `BatchFileList` unchanged in + behaviour. +3. Naming helper + list component with reorder, live labels and selection. +4. Route: options, stamping, per-row results, ZIP, error rows. +5. Preview panel. +6. Registry/icon/docs. +7. Playwright coverage + command gauntlet. + +## Verification + +- Three PDFs (1, 2, 3 pages), reorder the second to the front, prefix `Anlage K`, start 6, renaming + on → labels „Anlage K 6/7/8“, downloads named `Anlage_K_06_…`, `Anlage_K_07_…`, `Anlage_K_08_…`; + page 1 of each contains the label, page 2 is untouched (`getPageContentStreamCount` unchanged). +- Start 98 with three files → padding switches to three digits (`098`, `099`, `100`). +- Renaming off → downloads keep the original names; ZIP entries deduplicate on collisions. +- „Auf jeder Seite stempeln“ → every page of every exhibit contains the label. +- Reorder after a run → done rows keep their label and the hint is visible. +- Prefix validation (empty), encrypted PDF (row error, queue continues), 30-file ZIP. +- Single-page PDFs do not break the run. +- `pnpm check`, targeted `pnpm test:e2e`, `pnpm build`, Prettier on touched files. + +## Risks + +- **Number drift after edits:** mitigated by frozen numbers + hint; no silent renumber. +- **Drag-and-drop accessibility:** buttons remain the primary path and the tested one; DnD must have + a visible handle and keyboard alternative. +- **Memory:** results stay in memory like every batch tool; the existing warning (>20 files or + > 25 MB) applies. The preview renders only one page — never a thumbnail array. +- **Filename edge cases:** slugs from unusual prefixes (`Anlage K/1`) must not produce path + separators or empty names; validate and fall back to `Anlage`. +- **Rotated scans:** the shared overlay compensates page rotation for the new tools; stamp output + must stay unchanged. +- **Already-prefixed input files:** re-running the tool on its own output stacks prefixes. Document + it; no auto-detection in v1. + +## Out of scope + +- Grouping multiple PDFs into one Anlage. +- Anlagenverzeichnis / index page (a merged table of contents is a follow-up candidate). +- One merged „Anlagenkonvolut“ export. +- Per-page counters (pagination plan). +- Persisted prefixes or exhibit libraries. 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/playwright.config.ts b/playwright.config.ts index 1e5f266..d7dbe0c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,8 @@ const chromiumExecutable = (existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined); export default defineConfig({ - testDir: './tests/e2e', + // `tests/unit` holds pure-function specs (no browser); Playwright runs both. + testDir: './tests', fullyParallel: false, retries: 0, reporter: 'line', diff --git a/src/lib/batch.ts b/src/lib/batch.ts index 3a98c85..845c3ab 100644 --- a/src/lib/batch.ts +++ b/src/lib/batch.ts @@ -1,3 +1,6 @@ +import { zipSync } from 'fflate'; +import { downloadBlob } from './download'; + export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error'; export type BatchItem = { @@ -12,8 +15,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. @@ -31,10 +37,13 @@ export const createBatchItem = (file: File): BatchItem => ({ * Processes the given items strictly one after another so only one PDF is in * flight at a time. Never throws: a failing item is marked as an error row and * the queue continues with the remaining files. + * + * `process` receives the item as well, so tools can resolve per-row data (for + * example the exhibit number frozen when the run started). */ export const runBatch = async ( items: BatchItem[], - process: (file: File) => Promise, + process: (file: File, item: BatchItem) => Promise, update: (id: string, patch: Partial) => void, options: BatchRunOptions = {} ): Promise => { @@ -45,7 +54,7 @@ export const runBatch = async ( update(item.id, { status: 'processing', errorMessage: null }); try { - const result = await process(item.file); + const result = await process(item.file, item); update(item.id, { status: 'done', resultBytes: result.bytes, @@ -58,7 +67,8 @@ export const runBatch = async ( status: 'error', resultBytes: undefined, resultName: undefined, - errorMessage + errorMessage: + typeof errorMessage === 'function' ? errorMessage(error, item.file) : errorMessage }); } } @@ -69,3 +79,85 @@ export const formatBytes = (bytes: number) => { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; + +/** Badge colours for a batch row, shared by every batch list component. */ +export const batchStatusClass = (status: BatchItemStatus) => { + switch (status) { + case 'processing': + return 'bg-amber-100 text-amber-800'; + case 'done': + return 'bg-emerald-100 text-emerald-800'; + case 'error': + return 'bg-red-100 text-red-800'; + default: + return 'bg-accent text-primary'; + } +}; + +/** German badge label for a batch row. */ +export const batchStatusLabel = (status: BatchItemStatus) => { + switch (status) { + case 'processing': + return 'Verarbeite…'; + case 'done': + return 'Fertig ✓'; + case 'error': + return 'Fehler'; + default: + return 'Wartet'; + } +}; + +/** Above this many files (or one file above the threshold) memory gets tight. */ +const BATCH_MEMORY_FILE_COUNT = 20; +const BATCH_MEMORY_FILE_BYTES = 25 * 1024 * 1024; + +/** + * Shared memory hint for batch tools: very many or very large files keep every + * result in memory until the page is closed. + */ +export const shouldWarnAboutBatchMemory = (items: BatchItem[]) => + items.length > BATCH_MEMORY_FILE_COUNT || + items.some((item) => item.file.size > BATCH_MEMORY_FILE_BYTES); + +/** Appends `_2`, `_3`, … when a name is already taken inside the archive. */ +export const uniqueEntryName = (name: string, usedNames: Set) => { + if (!usedNames.has(name)) { + usedNames.add(name); + return name; + } + + const dotIndex = name.lastIndexOf('.'); + const base = dotIndex > 0 ? name.slice(0, dotIndex) : name; + const extension = dotIndex > 0 ? name.slice(dotIndex) : ''; + let counter = 2; + let candidate = `${base}_2${extension}`; + + while (usedNames.has(candidate)) { + counter += 1; + candidate = `${base}_${counter}${extension}`; + } + + usedNames.add(candidate); + return candidate; +}; + +/** + * Bundles every finished row into one `{zipBaseName}_{YYYY-MM-DD}.zip`. Kept out + * of the component so the next batch tool (exhibits) reuses the naming and the + * duplicate handling instead of copying them. + */ +export const downloadBatchZip = (items: BatchItem[], zipBaseName: string) => { + const entries: Record = {}; + const usedNames = new Set(); + + for (const item of items) { + if (item.status !== 'done' || !item.resultBytes || !item.resultName) continue; + entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes; + } + + if (Object.keys(entries).length === 0) return; + + const dateStamp = new Date().toISOString().slice(0, 10); + downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip'); +}; diff --git a/src/lib/components/AttachmentPreview.svelte b/src/lib/components/AttachmentPreview.svelte index f49f3ca..1edad9a 100644 --- a/src/lib/components/AttachmentPreview.svelte +++ b/src/lib/components/AttachmentPreview.svelte @@ -1,5 +1,6 @@ -
+

Dateien ({items.length})

{#if doneItems.length > 0} @@ -123,18 +71,18 @@
- {statusLabel(item.status)} + {batchStatusLabel(item.status)}
{#if item.status === 'done' && item.resultBytes && item.resultName} + +
+
+ +
+

+ {label} +

+

{item.file.name}

+ {#if renamed} +

+ → {renamed} +

+ {/if} + {#if item.status === 'error' && item.errorMessage} + + {/if} +
+ + + {batchStatusLabel(item.status)} + + +
+ + {#if item.status === 'done' && item.resultBytes && item.resultName} + + {/if} + {#if item.status === 'error'} + + {/if} + +
+ + {/each} + + + {#if doneItems.length >= 2} +
+ +
+ {/if} +
diff --git a/src/lib/components/PaginationPreview.svelte b/src/lib/components/PaginationPreview.svelte new file mode 100644 index 0000000..27ffebd --- /dev/null +++ b/src/lib/components/PaginationPreview.svelte @@ -0,0 +1,164 @@ + + +
+
+
+

Vorschau

+

Annäherung an die Ausgabe im PDF

+
+ Seite {currentPage} von {pageCount} +
+ + {#if error} + + {:else if preview} +
+ Vorschau von Seite {currentPage} +
+ {label} +
+
+ {:else} +
+ Vorschau wird geladen … +
+ {/if} + + {#if fit.tooLong} +

+ Der Text ist für diese Seite zu lang. Kürzen Sie den Zusatztext oder wählen Sie ein kürzeres + Zahlenformat. +

+ {:else if fit.shrunk} +

+ Auf dieser Seite wird der Text auf {shrunkFontSize} pt verkleinert. +

+ {/if} + + {#if pageCount > 1} +
+ + + +
+ {/if} +
diff --git a/src/lib/components/PdfSplitDialog.svelte b/src/lib/components/PdfSplitDialog.svelte index 38e1779..949abc1 100644 --- a/src/lib/components/PdfSplitDialog.svelte +++ b/src/lib/components/PdfSplitDialog.svelte @@ -3,6 +3,7 @@ import { X } from '@lucide/svelte'; import Button from '$lib/components/ui/button.svelte'; import PdfPageGrid from '$lib/components/PdfPageGrid.svelte'; + import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors'; import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails'; import { createAttachmentFromPages, @@ -172,7 +173,7 @@ onApply(await buildResult(groups)); } catch (error) { console.error(`Failed to split PDF attachment ${source.path}`, error); - splitError = 'Das PDF konnte nicht geteilt werden.'; + splitError = pdfProcessingErrorMessage(error, 'Das PDF konnte nicht geteilt werden.'); } finally { isProcessing = false; } @@ -209,7 +210,7 @@ console.error(`Failed to load PDF attachment ${source.path}`, error); if (!cancelled) { - loadError = 'PDF konnte nicht geladen werden.'; + loadError = pdfLoadErrorMessage(error); } } })(); @@ -237,16 +238,16 @@ data-split-dialog tabindex="-1" > -
+
-

PDF teilen

-

+

PDF teilen

+

{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}

-

+

{selectedPages.size} von {pageCount} Seiten ausgewählt

{/if} @@ -368,17 +369,17 @@