From 3b04039aa9fdd9703038c8fb26979a32d029dfb9 Mon Sep 17 00:00:00 2001 From: rehlert Date: Wed, 28 Oct 2026 16:09:53 +0100 Subject: [PATCH] refactoring, numbering plan --- agents.md | 81 ++- plan/done/exhibit-numbering-plan.md | 237 +++++++ playwright.config.ts | 3 +- src/lib/batch.ts | 92 ++- src/lib/components/AttachmentPreview.svelte | 9 +- src/lib/components/BatchFileList.svelte | 84 +-- src/lib/components/ExhibitFileList.svelte | 269 ++++++++ src/lib/components/PaginationPreview.svelte | 164 +++++ src/lib/components/PdfSplitDialog.svelte | 41 +- .../ProcessedZipArchiveEditor.svelte | 36 +- src/lib/components/SinglePdfTool.svelte | 73 ++ src/lib/components/ToolError.svelte | 20 + src/lib/download.ts | 34 +- src/lib/exhibit-naming.ts | 73 ++ src/lib/latest-task.ts | 26 + src/lib/pdf-errors.ts | 58 +- src/lib/pdf-overlay.ts | 58 +- src/lib/pdf-pagination.ts | 62 +- src/lib/pdf-preview-text.ts | 57 ++ src/lib/pdf-session.svelte.ts | 100 +++ src/lib/pdf-text-overlay.ts | 233 +++++-- src/lib/pdf-thumbnails.ts | 114 ++-- src/lib/pending-files.ts | 37 +- src/lib/tools.ts | 6 + src/routes/+page.svelte | 4 +- src/routes/tools/bea/+page.svelte | 2 +- src/routes/tools/compress/+page.svelte | 18 +- src/routes/tools/convert/+page.svelte | 36 +- src/routes/tools/decrypt/+page.svelte | 17 +- src/routes/tools/encrypt/+page.svelte | 17 +- src/routes/tools/exhibits/+page.svelte | 621 ++++++++++++++++++ src/routes/tools/merge/+page.svelte | 71 +- src/routes/tools/metadata/+page.svelte | 34 +- src/routes/tools/pagination/+page.svelte | 282 +++----- src/routes/tools/rotate/+page.svelte | 231 +++---- src/routes/tools/separate/+page.svelte | 148 ++--- src/routes/tools/stamp/+page.svelte | 143 ++-- src/routes/tools/watermark/+page.svelte | 19 +- tests/e2e/exhibits.spec.ts | 211 ++++++ tests/e2e/helpers.ts | 114 +++- tests/e2e/pagination.spec.ts | 71 +- tests/e2e/tools.spec.ts | 16 + tests/unit/exhibit-naming.spec.ts | 83 +++ tests/unit/pdf-errors.spec.ts | 40 ++ tests/unit/pdf-pagination.spec.ts | 131 ++++ tests/unit/pdf-text-overlay.spec.ts | 133 ++++ tests/unit/pending-files.spec.ts | 29 + 47 files changed, 3485 insertions(+), 953 deletions(-) create mode 100644 plan/done/exhibit-numbering-plan.md create mode 100644 src/lib/components/ExhibitFileList.svelte create mode 100644 src/lib/components/PaginationPreview.svelte create mode 100644 src/lib/components/SinglePdfTool.svelte create mode 100644 src/lib/components/ToolError.svelte create mode 100644 src/lib/exhibit-naming.ts create mode 100644 src/lib/latest-task.ts create mode 100644 src/lib/pdf-preview-text.ts create mode 100644 src/lib/pdf-session.svelte.ts create mode 100644 src/routes/tools/exhibits/+page.svelte create mode 100644 tests/e2e/exhibits.spec.ts create mode 100644 tests/e2e/tools.spec.ts create mode 100644 tests/unit/exhibit-naming.spec.ts create mode 100644 tests/unit/pdf-errors.spec.ts create mode 100644 tests/unit/pdf-pagination.spec.ts create mode 100644 tests/unit/pdf-text-overlay.spec.ts create mode 100644 tests/unit/pending-files.spec.ts diff --git a/agents.md b/agents.md index 04eb288..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,13 +93,18 @@ 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 encrypted-PDF detection and German messages +│ ├── 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 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-text-overlay.ts # Shared framed/rotated text overlay drawing +│ ├── 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 @@ -107,13 +116,15 @@ src/ ├── tools/ │ ├── +layout.svelte # Shared back-navigation shell │ ├── bea/+page.svelte # Existing ZIP archive workflow - │ └── {pagination,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 ``` @@ -156,6 +167,13 @@ committed. `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. @@ -242,11 +260,33 @@ All case-file processing is intentionally local to the browser. 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. + 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 revoke object - URLs and tear down the pdfjs loading task in `destroy()`. + 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. @@ -264,11 +304,18 @@ files. Follow the established compress retrofit when adding the pattern to anoth `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 (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 +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/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 @@ -280,8 +327,14 @@ options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PD Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead 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`, - encrypt `geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the + 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/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 498af78..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 = { @@ -34,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 => { @@ -48,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, @@ -73,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 @@
    {#each assignedGroups as group, index (index)}
  • - + {group.name} - + {describePages(group.pages)} + {/each} + + + +
    + + + {#if prefixMissing} +

    + Bitte geben Sie eine Bezeichnung an, z. B. „Anlage K“. +

    + {:else} +

    + Beispiel: {prefixExample} +

    + {/if} +
    + +
    + + (startNumberInput = startNumber)} + onblur={() => (startNumberInput = startNumber)} + class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm sm:w-28" + /> + {#if items.length > 0} +

    + {items.length} Datei{items.length === 1 ? '' : 'en'} → {effectivePrefix || + DEFAULT_EXHIBIT_PREFIX} + {numberExample}{items.length > 3 ? ', …' : ''} +

    + {/if} +
    + + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + + {#if rename} +

    + Muster: {renameExample} +

    + {/if} +
    + + {#if isRunning || hasResults} +

    + Bereits erstellte Anlagen behalten ihre Nummer. +

    + {/if} + + + {#if selectedId} +
    +
    +
    +

    + Vorschau +

    +

    + {selectedLabel} · Seite 1 von {selectedPageCount} +

    +
    +
    + + {#if previewError} + + {:else if preview} +
    + Vorschau von Seite 1 +
    +
    + {selectedLabel} +
    +
    +
    + {:else} +
    + Vorschau wird geladen … +
    + {/if} + + {#if previewFit.tooLong} +

    + Die Bezeichnung ist für diese Seite zu lang. Kürzen Sie sie. +

    + {:else if previewFit.shrunk} +

    + Auf dieser Seite wird die Bezeichnung verkleinert. +

    + {/if} +
    + {/if} + + + + +
    + + +
    + {:else} + + {/if} + diff --git a/src/routes/tools/merge/+page.svelte b/src/routes/tools/merge/+page.svelte index 40f6809..63070ac 100644 --- a/src/routes/tools/merge/+page.svelte +++ b/src/routes/tools/merge/+page.svelte @@ -1,16 +1,22 @@ @@ -86,22 +80,21 @@
    -
    - {#if error} -
    - {error} -
    - {/if} +
    {#each pdfFiles as file, index} diff --git a/src/routes/tools/metadata/+page.svelte b/src/routes/tools/metadata/+page.svelte index aa86ec0..d3fa849 100644 --- a/src/routes/tools/metadata/+page.svelte +++ b/src/routes/tools/metadata/+page.svelte @@ -1,8 +1,10 @@ @@ -206,13 +178,17 @@ 0 ? ` · ${pageCount} Seite${pageCount === 1 ? '' : 'n'}` : ''}` : 'PDF hier ablegen oder auswählen'} /> + {#if file} + + {/if} + {#if file && renderer}

    Die Nummern laufen über das gesamte Dokument und werden für schmale Seiten automatisch - verkleinert. + verkleinert (bis {MIN_FONT_SIZE} pt).

    @@ -236,11 +212,13 @@

    @@ -256,12 +234,14 @@ id="pagination-text" type="text" bind:value={freeText} - maxlength="60" + maxlength={MAX_FREE_TEXT_LENGTH} placeholder="z. B. 123 Ja 60/26" + aria-describedby="pagination-text-help" class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm" /> -

    - Höchstens 60 Zeichen, zum Beispiel ein Aktenzeichen. +

    + Höchstens {MAX_FREE_TEXT_LENGTH} Zeichen ({freeTextCount}), zum Beispiel ein + Aktenzeichen.

    @@ -300,7 +280,7 @@ bind:value={numberFormat} class="rounded-lg border border-input bg-background px-3 py-2 text-sm" > - {#each PAGINATION_FORMATS as format} + {#each PAGINATION_FORMATS as format (format.value)} {/each} @@ -314,13 +294,9 @@ bind:value={position} class="rounded-lg border border-input bg-background px-3 py-2 text-sm" > - - - - - - - + {#each OVERLAY_POSITION_OPTIONS as option (option.value)} + + {/each} @@ -335,112 +311,44 @@ min={MIN_FONT_SIZE} max={MAX_FONT_SIZE} bind:value={fontSize} + aria-valuetext="{fontSize} Punkt" class="sm:col-span-2" /> +

    + Wunschgröße; reicht der Platz nicht, wird der Text bis {MIN_FONT_SIZE} pt verkleinert. +

    Beispiel: {exampleLabel}

    -
    -
    -
    -

    - Vorschau -

    -

    Annäherung an die Ausgabe im PDF

    -
    - Seite {currentPage} von {pageCount} -
    - - {#if preview} -
    - Vorschau von Seite {currentPage} -
    - {previewLabel} -
    -
    - {:else} -
    - Vorschau wird geladen … -
    - {/if} - - {#if pageCount > 1} -
    - - - -
    - {/if} -
    + (previewRetry += 1)} + onShowPage={showPage} + /> {/if}
    - {#if error} - + + {#if renderer} + {/if} -
    diff --git a/src/routes/tools/rotate/+page.svelte b/src/routes/tools/rotate/+page.svelte index 55de1d0..f0c5bc9 100644 --- a/src/routes/tools/rotate/+page.svelte +++ b/src/routes/tools/rotate/+page.svelte @@ -2,52 +2,36 @@ import { onDestroy } from 'svelte'; import { degrees, PDFDocument } from 'pdf-lib'; import { RotateCcw, RotateCw } from '@lucide/svelte'; - import FileDropzone from '$lib/components/FileDropzone.svelte'; import PdfPageGrid from '$lib/components/PdfPageGrid.svelte'; + import SinglePdfTool from '$lib/components/SinglePdfTool.svelte'; import Button from '$lib/components/ui/button.svelte'; import { downloadBlob } from '$lib/download'; + import { pdfProcessingErrorMessage } from '$lib/pdf-errors'; + import { createPdfSession } from '$lib/pdf-session.svelte'; import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails'; - let pdfFile: File | null = $state(null); - let pdfHandle: PdfDocumentHandle | null = $state(null); let rotations = $state(new Map()); let isProcessing = $state(false); let error = $state(null); - let loadingError = $state(null); + const session = createPdfSession({ + load: (_file, bytes) => loadPdfDocument(bytes), + dispose: (handle) => handle.destroy(), + onFileChange: () => { + rotations = new Map(); + error = null; + } + }); + + const pdfFile = $derived(session.file); + const pdfHandle = $derived(session.handle); + const pageCount = $derived(pdfHandle?.pageCount ?? 0); const rotatedCount = $derived(rotations.size); const hasRotations = $derived(rotatedCount > 0); - const isEncryptedPdfError = (loadError: unknown) => { - const message = - loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError); - return /password|encrypted|encryption|verschl/i.test(message); - }; - - const loadErrorMessage = (loadError: unknown) => - isEncryptedPdfError(loadError) - ? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.' - : 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.'; - - const handleFileSelected = async (files: File[]) => { - const file = files[0]; - if (!file) return; - - pdfHandle?.destroy(); - pdfHandle = null; - pdfFile = file; - rotations = new Map(); - loadingError = null; - error = null; - - try { - const bytes = new Uint8Array(await file.arrayBuffer()); - pdfHandle = await loadPdfDocument(bytes); - } catch (err) { - console.error('Failed to load PDF:', err); - loadingError = loadErrorMessage(err); - pdfHandle = null; - } + const handleFileSelected = (files: File[]) => { + const [file] = files; + if (file) void session.load(file); }; const rotatePage = (pageNumber: number, delta: number) => { @@ -63,8 +47,7 @@ }; const rotateAll = (delta: number) => { - if (!pdfHandle) return; - for (let pageNumber = 1; pageNumber <= pdfHandle.pageCount; pageNumber += 1) { + for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) { rotatePage(pageNumber, delta); } }; @@ -88,13 +71,14 @@ }; const rotateAndDownload = async () => { - if (!pdfFile || !pdfHandle || !hasRotations) return; + const bytes = session.bytes; + const currentFile = session.file; + if (!bytes || !currentFile || !hasRotations) return; isProcessing = true; error = null; try { - const bytes = new Uint8Array(await pdfFile.arrayBuffer()); const pdfDoc = await PDFDocument.load(bytes); for (const [index, page] of pdfDoc.getPages().entries()) { const delta = rotations.get(index + 1); @@ -104,116 +88,91 @@ } const resultBytes = await pdfDoc.save(); - const baseName = pdfFile.name.replace(/\.pdf$/i, ''); + const baseName = currentFile.name.replace(/\.pdf$/i, ''); downloadBlob(resultBytes, `${baseName}_gedreht.pdf`); } catch (err) { console.error('Failed to rotate pages:', err); - error = isEncryptedPdfError(err) - ? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.' - : 'Fehler beim Drehen der Seiten.'; + error = pdfProcessingErrorMessage(err, 'Fehler beim Drehen der Seiten.'); } finally { isProcessing = false; } }; onDestroy(() => { - pdfHandle?.destroy(); + session.clear(); }); -
    -
    -

    Seiten drehen

    -

    - Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust. -

    -
    + + {#snippet actions()} + + + + + {/snippet} - {#if !pdfHandle} - - {#if loadingError} -
    - {loadingError} -
    - {/if} - {:else} -
    - -
    -

    - {pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'} -

    -
    - - - - -
    -
    - - - {#snippet pageActions(pageNumber)} - {#if (rotations.get(pageNumber) ?? 0) !== 0} - - {rotations.get(pageNumber)}° - - {/if} -
    - - -
    - {/snippet} -
    - - {#if error} -
    - {error} -
    + + {#snippet pageActions(pageNumber)} + {#if (rotations.get(pageNumber) ?? 0) !== 0} + + {rotations.get(pageNumber)}° + {/if} - -
    -

    - {rotatedCount} von {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'} - gedreht -

    - +
    + +
    -
    - {/if} -
    + {/snippet} + + + {#snippet footer()} +

    + {rotatedCount} von {pageCount} Seite{pageCount === 1 ? '' : 'n'} + gedreht +

    + + {/snippet} +
    diff --git a/src/routes/tools/separate/+page.svelte b/src/routes/tools/separate/+page.svelte index 5313dae..8c904c5 100644 --- a/src/routes/tools/separate/+page.svelte +++ b/src/routes/tools/separate/+page.svelte @@ -1,38 +1,34 @@ -
    -
    -

    Seiten entfernen

    -

    - Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten. + + {#snippet actions()} + + + {/snippet} + + + + {#snippet footer()} +

    + {selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt

    -
    - - {#if !pdfHandle} - - {#if loadingError} -
    - {loadingError} -
    - {/if} - {:else} -
    - -
    -

    - {pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'} -

    -
    - - -
    -
    - - - - {#if error} -
    - {error} -
    - {/if} - -
    -

    - {selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt -

    - -
    -
    - {/if} -
    + + {/snippet} + diff --git a/src/routes/tools/stamp/+page.svelte b/src/routes/tools/stamp/+page.svelte index 4b2480b..8f34721 100644 --- a/src/routes/tools/stamp/+page.svelte +++ b/src/routes/tools/stamp/+page.svelte @@ -1,23 +1,32 @@