Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe5476175 | ||
|
|
3b04039aa9 | ||
|
|
b5279434b2 |
No files matched your search
@@ -38,7 +38,7 @@ pnpm build # Create the adapter-node production build
|
|||||||
pnpm preview # Preview the production build
|
pnpm preview # Preview the production build
|
||||||
pnpm check # Run Svelte and TypeScript diagnostics
|
pnpm check # Run Svelte and TypeScript diagnostics
|
||||||
pnpm check:watch # Run diagnostics in watch mode
|
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 test:pwa # PWA offline/update tests against the production build (requires pnpm build)
|
||||||
pnpm lint # Check repository formatting with Prettier
|
pnpm lint # Check repository formatting with Prettier
|
||||||
pnpm format # Format the repository with Prettier
|
pnpm format # Format the repository with Prettier
|
||||||
@@ -74,9 +74,13 @@ src/
|
|||||||
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
|
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
|
||||||
│ │ ├── BeaArchiveProcessing.svelte
|
│ │ ├── BeaArchiveProcessing.svelte
|
||||||
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
|
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
|
||||||
|
│ │ ├── ExhibitFileList.svelte # Reorderable Anlagen list: labels, renames, results
|
||||||
│ │ ├── ProcessedZipArchiveEditor.svelte
|
│ │ ├── ProcessedZipArchiveEditor.svelte
|
||||||
|
│ │ ├── PaginationPreview.svelte # Rendered page preview incl. shrink hints and page nav
|
||||||
│ │ ├── PdfSplitDialog.svelte # Page-level PDF splitting into Teildokumente
|
│ │ ├── PdfSplitDialog.svelte # Page-level PDF splitting into Teildokumente
|
||||||
|
│ │ ├── SinglePdfTool.svelte # Shell for one-PDF tools (header, dropzone, slots)
|
||||||
│ │ ├── SubDocumentEditor.svelte
|
│ │ ├── SubDocumentEditor.svelte
|
||||||
|
│ │ ├── ToolError.svelte # Shared error banner (role="alert")
|
||||||
│ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
|
│ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
|
||||||
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
|
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
|
||||||
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
|
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
|
||||||
@@ -89,11 +93,19 @@ src/
|
|||||||
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
|
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
|
||||||
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
||||||
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
│ ├── 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-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-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-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
|
│ ├── utils.ts # cn() and shared component utility types
|
||||||
│ └── zip-processing.ts # Archive model, transforms, and PDF export
|
│ └── zip-processing.ts # Archive model, transforms, and PDF export
|
||||||
└── routes/
|
└── routes/
|
||||||
@@ -104,13 +116,15 @@ src/
|
|||||||
├── tools/
|
├── tools/
|
||||||
│ ├── +layout.svelte # Shared back-navigation shell
|
│ ├── +layout.svelte # Shared back-navigation shell
|
||||||
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
│ ├── 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
|
├── datenschutz/+page.svelte # Privacy policy
|
||||||
└── impressum/+page.svelte # Imprint
|
└── impressum/+page.svelte # Imprint
|
||||||
static/ # Public logo, footer art, PWA icons, and robots.txt
|
static/ # Public logo, footer art, PWA icons, and robots.txt
|
||||||
components.json # shadcn-svelte registry configuration
|
components.json # shadcn-svelte registry configuration
|
||||||
Dockerfile # Node adapter production image
|
Dockerfile # Node adapter production image
|
||||||
playwright.pwa.config.ts # Playwright config for `pnpm test:pwa`
|
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
|
plan/ # Design and implementation notes
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,7 +160,20 @@ committed.
|
|||||||
## Application flow and state
|
## Application flow and state
|
||||||
|
|
||||||
- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and
|
- `/` 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
|
- `/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' }`
|
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.
|
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
|
- Page-set copies (separate/extract tools, PDF splitting) share
|
||||||
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
|
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
|
||||||
semantics cannot drift apart.
|
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
|
- `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
|
rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
|
||||||
safeguards.
|
safeguards.
|
||||||
@@ -243,16 +299,23 @@ called in the browser.
|
|||||||
|
|
||||||
## Batch processing pattern for single-PDF tools
|
## Batch processing pattern for single-PDF tools
|
||||||
|
|
||||||
Tools that transform one PDF per run (compress, watermark, encrypt, …) are being retrofitted to
|
Tools that transform one PDF per run (compress, watermark, stamp, encrypt, decrypt) accept multiple
|
||||||
accept multiple files. Follow the established compress retrofit when adding the pattern to another
|
files. Follow the established compress retrofit when adding the pattern to another tool; see
|
||||||
tool; see `plan/batch-processing-plan.md` for the full design.
|
`plan/done/batch-processing-plan.md` for the full design.
|
||||||
|
|
||||||
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
|
- `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
|
options?)`, `formatBytes`, `batchStatusLabel`/`batchStatusClass` and
|
||||||
memory safety), never throws — per-item failures are caught and written to the item as a German
|
`shouldWarnAboutBatchMemory`. `process` is `(file, item) => Promise<{ bytes, name }>`: the item is
|
||||||
error message — and consults the optional `shouldProcess` predicate so rows removed mid-run are
|
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.
|
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
|
(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
|
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
|
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
|
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.
|
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
|
Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
|
||||||
of blocking the batch.
|
of blocking the batch. Keep thumbnails, previews, and page-specific selection to a single dropped
|
||||||
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, encrypt
|
file; multi-file runs apply the tool to every page of every file.
|
||||||
`geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the batch
|
- `src/lib/components/ExhibitFileList.svelte` is the other batch list, for tools where order and a
|
||||||
flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
|
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
|
## Svelte conventions
|
||||||
|
|
||||||
|
|||||||
@@ -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<BatchProcessResult>` 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.
|
||||||
@@ -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.
|
||||||
@@ -8,7 +8,8 @@ const chromiumExecutable =
|
|||||||
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
|
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
// `tests/unit` holds pure-function specs (no browser); Playwright runs both.
|
||||||
|
testDir: './tests',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
reporter: 'line',
|
reporter: 'line',
|
||||||
|
|||||||
+97
-5
@@ -1,3 +1,6 @@
|
|||||||
|
import { zipSync } from 'fflate';
|
||||||
|
import { downloadBlob } from './download';
|
||||||
|
|
||||||
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
|
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
|
||||||
|
|
||||||
export type BatchItem = {
|
export type BatchItem = {
|
||||||
@@ -12,8 +15,11 @@ export type BatchItem = {
|
|||||||
export type BatchProcessResult = { bytes: Uint8Array; name: string };
|
export type BatchProcessResult = { bytes: Uint8Array; name: string };
|
||||||
|
|
||||||
export type BatchRunOptions = {
|
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`
|
* Optional predicate consulted before each item; when it returns `false`
|
||||||
* (for example because the row was removed mid-run), the item is skipped.
|
* (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
|
* 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
|
* flight at a time. Never throws: a failing item is marked as an error row and
|
||||||
* the queue continues with the remaining files.
|
* 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 (
|
export const runBatch = async (
|
||||||
items: BatchItem[],
|
items: BatchItem[],
|
||||||
process: (file: File) => Promise<BatchProcessResult>,
|
process: (file: File, item: BatchItem) => Promise<BatchProcessResult>,
|
||||||
update: (id: string, patch: Partial<BatchItem>) => void,
|
update: (id: string, patch: Partial<BatchItem>) => void,
|
||||||
options: BatchRunOptions = {}
|
options: BatchRunOptions = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
@@ -45,7 +54,7 @@ export const runBatch = async (
|
|||||||
|
|
||||||
update(item.id, { status: 'processing', errorMessage: null });
|
update(item.id, { status: 'processing', errorMessage: null });
|
||||||
try {
|
try {
|
||||||
const result = await process(item.file);
|
const result = await process(item.file, item);
|
||||||
update(item.id, {
|
update(item.id, {
|
||||||
status: 'done',
|
status: 'done',
|
||||||
resultBytes: result.bytes,
|
resultBytes: result.bytes,
|
||||||
@@ -58,7 +67,8 @@ export const runBatch = async (
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
resultBytes: undefined,
|
resultBytes: undefined,
|
||||||
resultName: 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`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
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<string>) => {
|
||||||
|
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<string, Uint8Array> = {};
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
|
||||||
|
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');
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { pdfLoadErrorMessage } from '$lib/pdf-errors';
|
||||||
|
|
||||||
import type { ZipAttachment } from '$lib/zip-processing';
|
import type { ZipAttachment } from '$lib/zip-processing';
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
await loadingTask.destroy();
|
await loadingTask.destroy();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to render preview for ${attachment.path}`, error);
|
console.error(`Failed to render preview for ${attachment.path}`, error);
|
||||||
previewError = 'PDF-Vorschau konnte nicht geladen werden.';
|
previewError = pdfLoadErrorMessage(error, 'PDF-Vorschau konnte nicht geladen werden.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,19 +116,19 @@
|
|||||||
<div class={className}>
|
<div class={className}>
|
||||||
{#if previewError}
|
{#if previewError}
|
||||||
<div
|
<div
|
||||||
class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
|
class="border-primary-200 bg-primary-50 text-primary-800 flex h-full min-h-0 items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
|
||||||
>
|
>
|
||||||
{previewError}
|
{previewError}
|
||||||
</div>
|
</div>
|
||||||
{:else if imageUrl}
|
{:else if imageUrl}
|
||||||
<img
|
<img
|
||||||
alt={attachment.name}
|
alt={attachment.name}
|
||||||
class="block rounded-md border border-primary-100 bg-white object-contain"
|
class="border-primary-100 block rounded-md border bg-white object-contain"
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
|
class="border-primary-200 bg-primary-50 text-primary-800 flex items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
|
||||||
>
|
>
|
||||||
Vorschau wird erstellt
|
Vorschau wird erstellt
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
|
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
|
||||||
import { zipSync } from 'fflate';
|
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { downloadBlob } from '$lib/download';
|
import {
|
||||||
import { formatBytes, type BatchItem } from '$lib/batch';
|
batchStatusClass,
|
||||||
import { RASTERIZATION_WARNING_BYTES } from '$lib/rasterization-limits';
|
batchStatusLabel,
|
||||||
|
downloadBatchZip,
|
||||||
|
formatBytes,
|
||||||
|
shouldWarnAboutBatchMemory,
|
||||||
|
type BatchItem
|
||||||
|
} from '$lib/batch';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
items: BatchItem[];
|
items: BatchItem[];
|
||||||
@@ -22,68 +26,12 @@
|
|||||||
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
|
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
|
||||||
);
|
);
|
||||||
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
|
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
|
||||||
const showMemoryWarning = $derived(
|
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
|
||||||
items.length > 20 || items.some((item) => item.file.size > RASTERIZATION_WARNING_BYTES)
|
|
||||||
);
|
|
||||||
|
|
||||||
const uniqueEntryName = (name: string, usedNames: Set<string>) => {
|
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadZip = () => {
|
|
||||||
const entries: Record<string, Uint8Array> = {};
|
|
||||||
const usedNames = new Set<string>();
|
|
||||||
for (const item of doneItems) {
|
|
||||||
if (!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');
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusChipClass = (status: BatchItem['status']) => {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabel = (status: BatchItem['status']) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'processing':
|
|
||||||
return 'Verarbeite…';
|
|
||||||
case 'done':
|
|
||||||
return 'Fertig ✓';
|
|
||||||
case 'error':
|
|
||||||
return 'Fehler';
|
|
||||||
default:
|
|
||||||
return 'Wartet';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
|
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 class="text-lg font-semibold text-foreground">Dateien ({items.length})</h2>
|
<h2 class="text-lg font-semibold text-foreground">Dateien ({items.length})</h2>
|
||||||
{#if doneItems.length > 0}
|
{#if doneItems.length > 0}
|
||||||
@@ -123,18 +71,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {statusChipClass(
|
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
|
||||||
item.status
|
item.status
|
||||||
)}"
|
)}"
|
||||||
>
|
>
|
||||||
{statusLabel(item.status)}
|
{batchStatusLabel(item.status)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||||
aria-label="{item.file.name} herunterladen"
|
aria-label="{item.file.name} herunterladen"
|
||||||
title="{item.file.name} herunterladen"
|
title="{item.file.name} herunterladen"
|
||||||
onclick={() => onDownload(item)}
|
onclick={() => onDownload(item)}
|
||||||
@@ -145,7 +93,7 @@
|
|||||||
{#if item.status === 'error'}
|
{#if item.status === 'error'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||||
aria-label="{item.file.name} erneut versuchen"
|
aria-label="{item.file.name} erneut versuchen"
|
||||||
title="{item.file.name} erneut versuchen"
|
title="{item.file.name} erneut versuchen"
|
||||||
onclick={() => onRetry(item.id)}
|
onclick={() => onRetry(item.id)}
|
||||||
@@ -155,7 +103,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-40"
|
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
aria-label="{item.file.name} entfernen"
|
aria-label="{item.file.name} entfernen"
|
||||||
title="{item.file.name} entfernen"
|
title="{item.file.name} entfernen"
|
||||||
disabled={item.status === 'processing'}
|
disabled={item.status === 'processing'}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Download, Eye, GripVertical, RotateCcw, Trash2 } from '@lucide/svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import {
|
||||||
|
batchStatusClass,
|
||||||
|
batchStatusLabel,
|
||||||
|
downloadBatchZip,
|
||||||
|
formatBytes,
|
||||||
|
shouldWarnAboutBatchMemory,
|
||||||
|
type BatchItem
|
||||||
|
} from '$lib/batch';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: BatchItem[];
|
||||||
|
/** Resolved stamp label per row; frozen once the row was created. */
|
||||||
|
labels: Record<string, string>;
|
||||||
|
/** Renamed file per row, or `null` when renaming is switched off. */
|
||||||
|
fileNames: Record<string, string | null>;
|
||||||
|
selectedId: string | null;
|
||||||
|
/** Base file name of the bulk ZIP, e.g. `anlagen` → `anlagen_2026-10-06.zip`. */
|
||||||
|
zipBaseName: string;
|
||||||
|
/** Reordering and preview selection are blocked while a run is active. */
|
||||||
|
disabled?: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onMove: (from: number, to: number) => void;
|
||||||
|
onDownload: (item: BatchItem) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onRetry: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
labels,
|
||||||
|
fileNames,
|
||||||
|
selectedId,
|
||||||
|
zipBaseName,
|
||||||
|
disabled = false,
|
||||||
|
onSelect,
|
||||||
|
onMove,
|
||||||
|
onDownload,
|
||||||
|
onRemove,
|
||||||
|
onRetry
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let draggingId = $state<string | null>(null);
|
||||||
|
let dragOverId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const doneItems = $derived(items.filter((item) => item.status === 'done'));
|
||||||
|
const totalResultBytes = $derived(
|
||||||
|
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
|
||||||
|
);
|
||||||
|
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
|
||||||
|
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
|
||||||
|
|
||||||
|
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
|
||||||
|
|
||||||
|
const moveUp = (index: number) => {
|
||||||
|
if (disabled || index === 0) return;
|
||||||
|
onMove(index, index - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveDown = (index: number) => {
|
||||||
|
if (disabled || index === items.length - 1) return;
|
||||||
|
onMove(index, index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragEvent, item: BatchItem) => {
|
||||||
|
if (disabled) return;
|
||||||
|
draggingId = item.id;
|
||||||
|
event.dataTransfer?.setData('text/plain', item.id);
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent, item: BatchItem) => {
|
||||||
|
if (disabled || !draggingId || draggingId === item.id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
dragOverId = item.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent, item: BatchItem) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sourceId = draggingId;
|
||||||
|
draggingId = null;
|
||||||
|
dragOverId = null;
|
||||||
|
if (disabled || !sourceId || sourceId === item.id) return;
|
||||||
|
|
||||||
|
const from = items.findIndex((entry) => entry.id === sourceId);
|
||||||
|
const to = items.findIndex((entry) => entry.id === item.id);
|
||||||
|
if (from === -1 || to === -1) return;
|
||||||
|
onMove(from, to);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
draggingId = null;
|
||||||
|
dragOverId = null;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">Anlagen ({items.length})</h2>
|
||||||
|
{#if doneItems.length > 0}
|
||||||
|
<p class="text-sm text-primary">
|
||||||
|
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if processingIndex !== -1}
|
||||||
|
<p class="text-sm text-primary" role="status">
|
||||||
|
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showMemoryWarning}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
|
||||||
|
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="divide-y divide-border">
|
||||||
|
{#each items as item, index (item.id)}
|
||||||
|
{@const label = labels[item.id] ?? ''}
|
||||||
|
{@const renamed = fileNames[item.id] ?? null}
|
||||||
|
<li
|
||||||
|
class="flex flex-wrap items-center gap-3 py-3 {dragOverId === item.id
|
||||||
|
? 'bg-accent/40'
|
||||||
|
: ''} {draggingId === item.id ? 'opacity-60' : ''}"
|
||||||
|
data-exhibit-item={item.file.name}
|
||||||
|
data-exhibit-index={index + 1}
|
||||||
|
data-exhibit-label={label}
|
||||||
|
draggable={!disabled}
|
||||||
|
ondragstart={(event) => handleDragStart(event, item)}
|
||||||
|
ondragover={(event) => handleDragOver(event, item)}
|
||||||
|
ondrop={(event) => handleDrop(event, item)}
|
||||||
|
ondragend={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<span
|
||||||
|
class="cursor-grab p-1 text-muted-foreground {disabled ? 'opacity-40' : ''}"
|
||||||
|
aria-hidden="true"
|
||||||
|
title="Ziehen zum Verschieben"
|
||||||
|
>
|
||||||
|
<GripVertical class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold text-primary"
|
||||||
|
title="Position in der Liste"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="{item.file.name} nach oben verschieben"
|
||||||
|
title="Nach oben"
|
||||||
|
disabled={disabled || index === 0}
|
||||||
|
onclick={() => moveUp(index)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="{item.file.name} nach unten verschieben"
|
||||||
|
title="Nach unten"
|
||||||
|
disabled={disabled || index === items.length - 1}
|
||||||
|
onclick={() => moveDown(index)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm font-semibold text-foreground">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p class="truncate text-xs text-primary">{item.file.name}</p>
|
||||||
|
{#if renamed}
|
||||||
|
<p class="truncate text-xs text-muted-foreground" data-exhibit-filename>
|
||||||
|
→ {renamed}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if item.status === 'error' && item.errorMessage}
|
||||||
|
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
|
||||||
|
item.status
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{batchStatusLabel(item.status)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40 aria-pressed:bg-accent"
|
||||||
|
aria-label="Vorschau für {item.file.name}"
|
||||||
|
title="Vorschau für {item.file.name}"
|
||||||
|
aria-pressed={selectedId === item.id}
|
||||||
|
{disabled}
|
||||||
|
onclick={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||||
|
aria-label="{item.file.name} herunterladen"
|
||||||
|
title="{item.file.name} herunterladen"
|
||||||
|
onclick={() => onDownload(item)}
|
||||||
|
>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if item.status === 'error'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="{item.file.name} erneut versuchen"
|
||||||
|
title="{item.file.name} erneut versuchen"
|
||||||
|
{disabled}
|
||||||
|
onclick={() => onRetry(item.id)}
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="{item.file.name} entfernen"
|
||||||
|
title="{item.file.name} entfernen"
|
||||||
|
disabled={item.status === 'processing'}
|
||||||
|
onclick={() => onRemove(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if doneItems.length >= 2}
|
||||||
|
<div class="flex justify-end pt-1">
|
||||||
|
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { MIN_FONT_SIZE, PAGE_NUMBER_COLOR, PAGE_NUMBER_MARGIN } from '$lib/pdf-pagination';
|
||||||
|
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
|
||||||
|
import { getTextFitScale } from '$lib/pdf-preview-text';
|
||||||
|
import { PDF_PREVIEW_SCALE, type PdfPageImage } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Rendered page bitmap, or `null` while the first render is pending. */
|
||||||
|
preview: PdfPageImage | null;
|
||||||
|
/** Message shown instead of the bitmap when rendering failed. */
|
||||||
|
error: string | null;
|
||||||
|
/** Label the export will stamp on this page. */
|
||||||
|
label: string;
|
||||||
|
fontSize: number;
|
||||||
|
position: OverlayPosition;
|
||||||
|
currentPage: number;
|
||||||
|
pageCount: number;
|
||||||
|
onRetry: () => void;
|
||||||
|
onShowPage: (pageNumber: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
preview,
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
fontSize,
|
||||||
|
position,
|
||||||
|
currentPage,
|
||||||
|
pageCount,
|
||||||
|
onRetry,
|
||||||
|
onShowPage
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const pageSize = $derived(
|
||||||
|
preview
|
||||||
|
? { width: preview.width / PDF_PREVIEW_SCALE, height: preview.height / PDF_PREVIEW_SCALE }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The export shrinks a label that does not fit (down to `MIN_FONT_SIZE`), so
|
||||||
|
* the preview applies the same factor instead of clipping.
|
||||||
|
*/
|
||||||
|
const fit = $derived.by(() => {
|
||||||
|
if (!pageSize) return { scale: 1, shrunk: false, tooLong: false };
|
||||||
|
|
||||||
|
const raw = getTextFitScale(
|
||||||
|
label,
|
||||||
|
fontSize,
|
||||||
|
pageSize.width - PAGE_NUMBER_MARGIN * 2,
|
||||||
|
pageSize.height - PAGE_NUMBER_MARGIN * 2
|
||||||
|
);
|
||||||
|
const minScale = MIN_FONT_SIZE / fontSize;
|
||||||
|
|
||||||
|
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewFontSize = $derived(
|
||||||
|
preview ? (fontSize * fit.scale * PDF_PREVIEW_SCALE * 100) / preview.width : 0
|
||||||
|
);
|
||||||
|
const shrunkFontSize = $derived(Math.max(MIN_FONT_SIZE, Math.round(fontSize * fit.scale)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="pagination-preview-title"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="pagination-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold text-primary">Seite {currentPage} von {pageCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-foreground">{error}</p>
|
||||||
|
<Button variant="tertiary" onclick={onRetry}>Erneut versuchen</Button>
|
||||||
|
</div>
|
||||||
|
{:else if preview}
|
||||||
|
<div
|
||||||
|
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||||
|
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={preview.imageUrl}
|
||||||
|
alt="Vorschau von Seite {currentPage}"
|
||||||
|
class="block h-full w-full"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute max-w-[92%] leading-tight whitespace-nowrap"
|
||||||
|
style="{getPreviewPositionStyle(
|
||||||
|
position
|
||||||
|
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_COLOR};"
|
||||||
|
data-testid="pagination-preview"
|
||||||
|
data-position={position}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Vorschau wird geladen …
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if fit.tooLong}
|
||||||
|
<p class="text-xs text-destructive" role="status">
|
||||||
|
Der Text ist für diese Seite zu lang. Kürzen Sie den Zusatztext oder wählen Sie ein kürzeres
|
||||||
|
Zahlenformat.
|
||||||
|
</p>
|
||||||
|
{:else if fit.shrunk}
|
||||||
|
<p class="text-xs text-muted-foreground" role="status">
|
||||||
|
Auf dieser Seite wird der Text auf {shrunkFontSize} pt verkleinert.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pageCount > 1}
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={() => onShowPage(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Vorherige Seite"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={() => onShowPage(currentPage + 1)}
|
||||||
|
disabled={currentPage === pageCount}
|
||||||
|
aria-label="Nächste Seite"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="pagination-page">
|
||||||
|
<span class="text-xs text-muted-foreground">Seite</span>
|
||||||
|
<input
|
||||||
|
id="pagination-page"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
max={pageCount}
|
||||||
|
value={currentPage}
|
||||||
|
oninput={(event) => onShowPage(Number(event.currentTarget.value))}
|
||||||
|
onblur={(event) => (event.currentTarget.value = String(currentPage))}
|
||||||
|
aria-label="Springe zu Seite"
|
||||||
|
class="w-20 rounded-lg border border-input bg-background px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { X } from '@lucide/svelte';
|
import { X } from '@lucide/svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
import {
|
import {
|
||||||
createAttachmentFromPages,
|
createAttachmentFromPages,
|
||||||
@@ -172,7 +173,7 @@
|
|||||||
onApply(await buildResult(groups));
|
onApply(await buildResult(groups));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to split PDF attachment ${source.path}`, 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 {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
@@ -209,7 +210,7 @@
|
|||||||
console.error(`Failed to load PDF attachment ${source.path}`, error);
|
console.error(`Failed to load PDF attachment ${source.path}`, error);
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
loadError = 'PDF konnte nicht geladen werden.';
|
loadError = pdfLoadErrorMessage(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -237,16 +238,16 @@
|
|||||||
data-split-dialog
|
data-split-dialog
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<header class="flex items-center justify-between gap-3 border-b border-primary-100 px-5 py-4">
|
<header class="border-primary-100 flex items-center justify-between gap-3 border-b px-5 py-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="text-[16px] font-bold text-primary-900">PDF teilen</h2>
|
<h2 class="text-primary-900 text-[16px] font-bold">PDF teilen</h2>
|
||||||
<p class="truncate text-[13px] text-primary-700" title={source.name}>
|
<p class="text-primary-700 truncate text-[13px]" title={source.name}>
|
||||||
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50"
|
class="border-primary-200 text-primary-700 hover:border-primary-400 hover:bg-primary-50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border bg-white transition"
|
||||||
aria-label="Teilen abbrechen"
|
aria-label="Teilen abbrechen"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
>
|
>
|
||||||
@@ -256,7 +257,7 @@
|
|||||||
|
|
||||||
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||||
<div
|
<div
|
||||||
class="inline-flex flex-wrap items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
|
class="border-primary-200 inline-flex flex-wrap items-center rounded-[16px] border bg-white p-0.5"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Art des Teilens"
|
aria-label="Art des Teilens"
|
||||||
>
|
>
|
||||||
@@ -283,10 +284,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if mode === 'everyN'}
|
{#if mode === 'everyN'}
|
||||||
<label class="flex flex-wrap items-center gap-2 text-[13px] font-semibold text-primary-900">
|
<label class="text-primary-900 flex flex-wrap items-center gap-2 text-[13px] font-semibold">
|
||||||
Seiten pro Teildokument
|
Seiten pro Teildokument
|
||||||
<input
|
<input
|
||||||
class="w-20 rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-20 rounded-md border bg-white px-2 py-1 text-[13px] font-medium transition outline-none focus:ring-2"
|
||||||
type="number"
|
type="number"
|
||||||
min={2}
|
min={2}
|
||||||
value={chunkSize}
|
value={chunkSize}
|
||||||
@@ -298,7 +299,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mode !== 'manual' && pageCount > 0}
|
{#if mode !== 'manual' && pageCount > 0}
|
||||||
<p class="text-[13px] font-medium text-primary-700" aria-live="polite">
|
<p class="text-primary-700 text-[13px] font-medium" aria-live="polite">
|
||||||
Es werden {bulkChunks.length}
|
Es werden {bulkChunks.length}
|
||||||
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
|
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
|
||||||
</p>
|
</p>
|
||||||
@@ -329,7 +330,7 @@
|
|||||||
</PdfPageGrid>
|
</PdfPageGrid>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-10 text-[13px] text-primary-700"
|
class="border-primary-200 bg-primary-50 text-primary-700 flex items-center justify-center rounded-md border border-dashed px-4 py-10 text-[13px]"
|
||||||
>
|
>
|
||||||
Vorschau wird erstellt
|
Vorschau wird erstellt
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +341,7 @@
|
|||||||
<label class="flex min-w-0 flex-1 items-center gap-2">
|
<label class="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<span class="sr-only">Name des Teildokuments</span>
|
<span class="sr-only">Name des Teildokuments</span>
|
||||||
<input
|
<input
|
||||||
class="w-full min-w-40 rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[13px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full min-w-40 rounded-md border bg-white px-3 py-1.5 text-[13px] font-semibold transition outline-none focus:ring-2"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={selectedPages.size > 0
|
placeholder={selectedPages.size > 0
|
||||||
? defaultGroupName([...selectedPages])
|
? defaultGroupName([...selectedPages])
|
||||||
@@ -359,7 +360,7 @@
|
|||||||
Als Teildokument übernehmen
|
Als Teildokument übernehmen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[13px] text-primary-700" aria-live="polite">
|
<p class="text-primary-700 text-[13px]" aria-live="polite">
|
||||||
{selectedPages.size} von {pageCount} Seiten ausgewählt
|
{selectedPages.size} von {pageCount} Seiten ausgewählt
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -368,17 +369,17 @@
|
|||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col gap-2">
|
||||||
{#each assignedGroups as group, index (index)}
|
{#each assignedGroups as group, index (index)}
|
||||||
<li
|
<li
|
||||||
class="flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50 px-3 py-1.5"
|
class="border-primary-200 bg-primary-50 flex flex-wrap items-center gap-2 rounded-xl border px-3 py-1.5"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-primary-900">
|
<span class="text-primary-900 min-w-0 flex-1 truncate text-[13px] font-semibold">
|
||||||
{group.name}
|
{group.name}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[12px] font-medium text-primary-700">
|
<span class="text-primary-700 text-[12px] font-medium">
|
||||||
{describePages(group.pages)}
|
{describePages(group.pages)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-primary-600 transition hover:bg-white hover:text-red-700"
|
class="text-primary-600 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition hover:bg-white hover:text-red-700"
|
||||||
aria-label={`Zuordnung „${group.name}“ aufheben`}
|
aria-label={`Zuordnung „${group.name}“ aufheben`}
|
||||||
onclick={() => removeGroup(index)}
|
onclick={() => removeGroup(index)}
|
||||||
>
|
>
|
||||||
@@ -395,9 +396,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-[13px] font-medium text-primary-900">
|
<label class="text-primary-900 flex items-center gap-2 text-[13px] font-medium">
|
||||||
<input
|
<input
|
||||||
class="h-4 w-4 accent-primary-700"
|
class="accent-primary-700 h-4 w-4"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={keepSource}
|
checked={keepSource}
|
||||||
onchange={(event) => {
|
onchange={(event) => {
|
||||||
@@ -409,7 +410,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer
|
<footer
|
||||||
class="flex flex-wrap items-center justify-end gap-2 border-t border-primary-100 px-5 py-4"
|
class="border-primary-100 flex flex-wrap items-center justify-end gap-2 border-t px-5 py-4"
|
||||||
>
|
>
|
||||||
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
|
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||||
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
|
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
|
||||||
|
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
||||||
import {
|
import {
|
||||||
applyPdfSplit,
|
applyPdfSplit,
|
||||||
@@ -447,7 +448,7 @@
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to export archive ${archive.name}`, error);
|
console.error(`Failed to export archive ${archive.name}`, error);
|
||||||
mergeError = 'PDF konnte nicht erstellt werden.';
|
mergeError = pdfProcessingErrorMessage(error, 'PDF konnte nicht erstellt werden.');
|
||||||
} finally {
|
} finally {
|
||||||
isMerging = false;
|
isMerging = false;
|
||||||
}
|
}
|
||||||
@@ -470,7 +471,10 @@
|
|||||||
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
|
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
|
mergeError = pdfProcessingErrorMessage(
|
||||||
|
error,
|
||||||
|
`Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
downloadingSubDocumentId = null;
|
downloadingSubDocumentId = null;
|
||||||
}
|
}
|
||||||
@@ -480,14 +484,14 @@
|
|||||||
<svelte:window onkeydown={handleWindowKeydown} />
|
<svelte:window onkeydown={handleWindowKeydown} />
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="flex flex-col gap-4 rounded-2xl border border-primary-100 bg-white px-4 py-4 shadow-sm"
|
class="border-primary-100 flex flex-col gap-4 rounded-2xl border bg-white px-4 py-4 shadow-sm"
|
||||||
>
|
>
|
||||||
<header class="flex flex-wrap items-baseline justify-between gap-2 text-primary">
|
<header class="flex flex-wrap items-baseline justify-between gap-2 text-primary">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<label class="block min-w-0">
|
<label class="block min-w-0">
|
||||||
<span class="sr-only">Archivname</span>
|
<span class="sr-only">Archivname</span>
|
||||||
<input
|
<input
|
||||||
class="w-full max-w-xl rounded-md border border-primary-200 bg-white px-3 py-2 text-[16px] font-bold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full max-w-xl rounded-md border bg-white px-3 py-2 text-[16px] font-bold transition outline-none focus:ring-2"
|
||||||
type="text"
|
type="text"
|
||||||
value={archiveName}
|
value={archiveName}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
@@ -532,7 +536,7 @@
|
|||||||
{#if hasSubDocuments}
|
{#if hasSubDocuments}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
|
'border-primary-200 inline-flex items-center rounded-[16px] border bg-white p-0.5',
|
||||||
downloadingSubDocumentId !== null ? 'opacity-60' : ''
|
downloadingSubDocumentId !== null ? 'opacity-60' : ''
|
||||||
)}
|
)}
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
@@ -584,7 +588,7 @@
|
|||||||
|
|
||||||
{#if groupingMode}
|
{#if groupingMode}
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-2 text-[13px] font-medium text-primary-900"
|
class="border-primary-200 bg-primary-50 text-primary-900 flex flex-wrap items-center gap-3 rounded-xl border px-4 py-2 text-[13px] font-medium"
|
||||||
>
|
>
|
||||||
<span aria-live="polite">
|
<span aria-live="polite">
|
||||||
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
|
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
|
||||||
@@ -634,10 +638,10 @@
|
|||||||
{#each looseAttachments as attachment, index (attachment.path)}
|
{#each looseAttachments as attachment, index (attachment.path)}
|
||||||
<li
|
<li
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex flex-col shrink-0 items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
|
'flex shrink-0 flex-col items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
|
||||||
dropIndex === index ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
|
dropIndex === index ? 'border-primary-400 ring-primary-200 ring-1' : 'border-primary-100',
|
||||||
dragIndex === index ? 'opacity-60' : '',
|
dragIndex === index ? 'opacity-60' : '',
|
||||||
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
|
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-primary-300 ring-2' : ''
|
||||||
)}
|
)}
|
||||||
style={`width: ${thumbnailWidth}px;`}
|
style={`width: ${thumbnailWidth}px;`}
|
||||||
draggable={groupingMode ? 'false' : 'true'}
|
draggable={groupingMode ? 'false' : 'true'}
|
||||||
@@ -648,9 +652,9 @@
|
|||||||
>
|
>
|
||||||
{#if groupingMode}
|
{#if groupingMode}
|
||||||
<label class="flex cursor-pointer flex-col gap-2">
|
<label class="flex cursor-pointer flex-col gap-2">
|
||||||
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
|
<div class="text-primary-900 flex items-center gap-2 px-1 text-[13px] font-medium">
|
||||||
<input
|
<input
|
||||||
class="h-4 w-4 accent-primary-700"
|
class="accent-primary-700 h-4 w-4"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedPaths.has(attachment.path)}
|
checked={selectedPaths.has(attachment.path)}
|
||||||
onchange={() => toggleSelection(attachment.path)}
|
onchange={() => toggleSelection(attachment.path)}
|
||||||
@@ -661,7 +665,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
class="text-primary-900 truncate text-[13px] leading-tight font-medium"
|
||||||
title={attachment.name}
|
title={attachment.name}
|
||||||
>
|
>
|
||||||
{attachment.name}
|
{attachment.name}
|
||||||
@@ -671,7 +675,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
|
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
|
||||||
<button
|
<button
|
||||||
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
|
class="border-primary-200 text-primary-700 w-fit cursor-grab rounded-md border bg-white px-2 py-1 text-sm leading-none font-semibold active:cursor-grabbing"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Datei verschieben"
|
aria-label="Datei verschieben"
|
||||||
@@ -680,7 +684,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
|
class="text-primary-900 min-w-0 flex-1 truncate text-[13px] leading-tight font-medium"
|
||||||
title={attachment.name}
|
title={attachment.name}
|
||||||
>
|
>
|
||||||
{attachment.name}
|
{attachment.name}
|
||||||
@@ -703,7 +707,7 @@
|
|||||||
|
|
||||||
{#if looseAttachments.length === 0 && subDocuments.length === 0}
|
{#if looseAttachments.length === 0 && subDocuments.length === 0}
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-4 text-[13px] text-primary-700"
|
class="border-primary-200 bg-primary-50 text-primary-700 rounded-md border border-dashed px-4 py-4 text-[13px]"
|
||||||
>
|
>
|
||||||
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
|
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
|
||||||
</div>
|
</div>
|
||||||
@@ -758,7 +762,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialog.Content class="max-w-sm">
|
<AlertDialog.Content class="max-w-sm">
|
||||||
<AlertDialog.Title class="text-base font-bold text-primary-900">
|
<AlertDialog.Title class="text-primary-900 text-base font-bold">
|
||||||
Datei entfernen?
|
Datei entfernen?
|
||||||
</AlertDialog.Title>
|
</AlertDialog.Title>
|
||||||
<AlertDialog.Description>
|
<AlertDialog.Description>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
file: File | null;
|
||||||
|
/** `0` while no PDF is loaded; switches the shell to the bare dropzone. */
|
||||||
|
pageCount: number;
|
||||||
|
loadingError: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
onFilesSelected: (files: File[]) => void;
|
||||||
|
/** Extra toolbar buttons next to the file name. */
|
||||||
|
actions?: Snippet;
|
||||||
|
/** Main content, e.g. the page grid. */
|
||||||
|
children: Snippet;
|
||||||
|
/** Status line plus primary action, laid out `space-between`. */
|
||||||
|
footer: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
file,
|
||||||
|
pageCount,
|
||||||
|
loadingError,
|
||||||
|
error,
|
||||||
|
onFilesSelected,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
footer
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-primary-900 text-2xl font-bold">{title}</h1>
|
||||||
|
<p class="text-primary-700 mt-2 text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pageCount === 0}
|
||||||
|
<FileDropzone {onFilesSelected} />
|
||||||
|
<ToolError message={loadingError} class="mt-4" />
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FileDropzone
|
||||||
|
{onFilesSelected}
|
||||||
|
label="Anderes PDF ablegen"
|
||||||
|
sublabel={file?.name ?? ''}
|
||||||
|
class="py-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 class="text-primary-900 text-lg font-semibold">
|
||||||
|
{file?.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{@render actions?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
|
||||||
|
<ToolError message={error} />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
{@render footer()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** German message; nothing is rendered when the value is empty. */
|
||||||
|
message?: string | null;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { message, class: className = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<div
|
||||||
|
class={cn('rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800', className)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
+20
-14
@@ -1,19 +1,12 @@
|
|||||||
export const downloadBlob = (
|
/**
|
||||||
bytes: Uint8Array,
|
* Copies PDF bytes into a tightly sized buffer before wrapping them in a Blob.
|
||||||
fileName: string,
|
* pdf-lib returns a `Uint8Array` that may be a view into a larger buffer, so the
|
||||||
mimeType: string = 'application/pdf'
|
* copy is required — one place decides this instead of every tool doing it.
|
||||||
) => {
|
*/
|
||||||
|
export const pdfBytesToBlob = (bytes: Uint8Array, mimeType: string = 'application/pdf'): Blob => {
|
||||||
const buffer = new Uint8Array(bytes.byteLength);
|
const buffer = new Uint8Array(bytes.byteLength);
|
||||||
buffer.set(bytes);
|
buffer.set(bytes);
|
||||||
const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
|
return new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
const downloadLink = document.createElement('a');
|
|
||||||
|
|
||||||
downloadLink.href = objectUrl;
|
|
||||||
downloadLink.download = fileName;
|
|
||||||
downloadLink.rel = 'noopener';
|
|
||||||
downloadLink.click();
|
|
||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
|
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
|
||||||
@@ -23,3 +16,16 @@ export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
|
|||||||
downloadLink.rel = 'noopener';
|
downloadLink.rel = 'noopener';
|
||||||
downloadLink.click();
|
downloadLink.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Triggers a browser download for an existing Blob. */
|
||||||
|
export const downloadBlobFile = (blob: Blob, fileName: string) => {
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
downloadBlobUrl(objectUrl, fileName);
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadBlob = (
|
||||||
|
bytes: Uint8Array,
|
||||||
|
fileName: string,
|
||||||
|
mimeType: string = 'application/pdf'
|
||||||
|
) => downloadBlobFile(pdfBytesToBlob(bytes, mimeType), fileName);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Naming helpers for the exhibit numbering tool. Kept pure so the label the
|
||||||
|
* stamp draws and the file name the download gets can be tested without pdf-lib.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Longest allowed exhibit designation (matches the input's `maxlength`). */
|
||||||
|
export const MAX_EXHIBIT_PREFIX_LENGTH = 40;
|
||||||
|
|
||||||
|
/** Designation used when the user leaves the field empty. */
|
||||||
|
export const DEFAULT_EXHIBIT_PREFIX = 'Anlage';
|
||||||
|
|
||||||
|
/** Characters that must never reach a file name on Windows, macOS or Linux. */
|
||||||
|
const UNSAFE_FILE_NAME_CHARS = /[/\\:*?"<>|\u0000-\u001f]/g;
|
||||||
|
|
||||||
|
export const MIN_EXHIBIT_START_NUMBER = 0;
|
||||||
|
export const MAX_EXHIBIT_START_NUMBER = 99999;
|
||||||
|
export const DEFAULT_EXHIBIT_START_NUMBER = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamps a typed start number. Empty or non-numeric input (a field being
|
||||||
|
* cleared) falls back to the default instead of producing `NaN` labels.
|
||||||
|
*/
|
||||||
|
export const normalizeExhibitStartNumber = (value: number | string | null | undefined): number => {
|
||||||
|
const parsed = Math.round(Number(value));
|
||||||
|
if (value === null || value === '' || !Number.isFinite(parsed)) {
|
||||||
|
return DEFAULT_EXHIBIT_START_NUMBER;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(MAX_EXHIBIT_START_NUMBER, Math.max(MIN_EXHIBIT_START_NUMBER, parsed));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Trims and caps the designation; an empty result means "not filled in yet". */
|
||||||
|
export const normalizeExhibitPrefix = (prefix: string): string =>
|
||||||
|
prefix.trim().slice(0, MAX_EXHIBIT_PREFIX_LENGTH);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp text: designation plus number with exactly one space and no leading
|
||||||
|
* zeros — `Anlage K 6`, never `Anlage K 06`.
|
||||||
|
*/
|
||||||
|
export const buildExhibitLabel = (prefix: string, number: number): string =>
|
||||||
|
`${normalizeExhibitPrefix(prefix)} ${number}`;
|
||||||
|
|
||||||
|
/** File-name form of the designation: `Anlage K` → `Anlage_K`. */
|
||||||
|
export const buildExhibitPrefixSlug = (prefix: string): string =>
|
||||||
|
normalizeExhibitPrefix(prefix).replace(/\s+/g, '_').replace(UNSAFE_FILE_NAME_CHARS, '') ||
|
||||||
|
DEFAULT_EXHIBIT_PREFIX;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero-padding width: at least two digits, more when the highest assigned number
|
||||||
|
* needs them (1…99 → `06`, 1…100 → `006`).
|
||||||
|
*/
|
||||||
|
export const getExhibitNumberWidth = (highestNumber: number): number => {
|
||||||
|
const digits = Number.isFinite(highestNumber) ? String(Math.abs(highestNumber)).length : 1;
|
||||||
|
return Math.max(2, digits);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{prefixSlug}_{paddedNumber}_{originalBase}.pdf` — the optional renaming
|
||||||
|
* described in the plan, e.g. `Anlage_K_06_Klageschrift.pdf`.
|
||||||
|
*/
|
||||||
|
export const buildExhibitFileName = (options: {
|
||||||
|
prefix: string;
|
||||||
|
number: number;
|
||||||
|
highestNumber: number;
|
||||||
|
originalName: string;
|
||||||
|
}): string => {
|
||||||
|
const width = getExhibitNumberWidth(options.highestNumber);
|
||||||
|
const paddedNumber = String(options.number).padStart(width, '0');
|
||||||
|
const originalBase = options.originalName.replace(/\.pdf$/i, '');
|
||||||
|
const originalSlug = originalBase.replace(UNSAFE_FILE_NAME_CHARS, '') || 'Dokument';
|
||||||
|
|
||||||
|
return `${buildExhibitPrefixSlug(options.prefix)}_${paddedNumber}_${originalSlug}.pdf`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type LatestToken = {
|
||||||
|
/** Starts a new operation and invalidates every earlier ticket. */
|
||||||
|
begin: () => number;
|
||||||
|
/** True while `ticket` is still the most recent `begin()`. */
|
||||||
|
isCurrent: (ticket: number) => boolean;
|
||||||
|
/** Invalidates the current ticket without starting a new operation. */
|
||||||
|
invalidate: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards async work so that only the most recent call may publish its result.
|
||||||
|
* Replaces the hand-rolled `generation`/`token` counters that every PDF route
|
||||||
|
* used to carry (stamp, pagination, page previews); late callers check
|
||||||
|
* `isCurrent` and clean up their own resources instead of writing stale state.
|
||||||
|
*/
|
||||||
|
export const createLatestToken = (): LatestToken => {
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
begin: () => (current += 1),
|
||||||
|
isCurrent: (ticket) => ticket === current,
|
||||||
|
invalidate: () => {
|
||||||
|
current += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const ENCRYPTED_PDF_PATTERN = /password|encrypted|encryption|verschl/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by the overlay helpers for problems the user can fix (text too long,
|
||||||
|
* characters the standard font cannot encode). Callers render `message`
|
||||||
|
* directly instead of guessing from error text.
|
||||||
|
*
|
||||||
|
* Lives here rather than in `pdf-text-overlay.ts` so that tools which only need
|
||||||
|
* the error mapping do not pull pdf-lib into their bundle.
|
||||||
|
*/
|
||||||
|
export class TextOverlayError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'TextOverlayError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PdfErrorKind = 'encrypted' | 'text-overlay' | 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pdf-lib (`EncryptedPDFError`) and pdfjs (`PasswordException`) both surface
|
||||||
|
* password protection through the error name/message. Tools use this to show a
|
||||||
|
* single German hint instead of a generic failure.
|
||||||
|
*/
|
||||||
|
export const isEncryptedPdfError = (error: unknown): boolean => {
|
||||||
|
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error);
|
||||||
|
return ENCRYPTED_PDF_PATTERN.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a PDF failure so routes can pick copy without matching on
|
||||||
|
* pdf-lib/pdfjs message text themselves.
|
||||||
|
*/
|
||||||
|
export const classifyPdfError = (error: unknown): PdfErrorKind => {
|
||||||
|
if (error instanceof TextOverlayError) return 'text-overlay';
|
||||||
|
if (isEncryptedPdfError(error)) return 'encrypted';
|
||||||
|
return 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENCRYPTED_PDF_MESSAGE =
|
||||||
|
'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.';
|
||||||
|
|
||||||
|
const GENERIC_LOAD_MESSAGE =
|
||||||
|
'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing message for a failed PDF operation. Encryption and overlay errors
|
||||||
|
* keep their specific German copy; everything else falls back to `fallback`.
|
||||||
|
*/
|
||||||
|
export const pdfErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
|
switch (classifyPdfError(error)) {
|
||||||
|
case 'encrypted':
|
||||||
|
return ENCRYPTED_PDF_MESSAGE;
|
||||||
|
case 'text-overlay':
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
default:
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pdfLoadErrorMessage = (error: unknown, fallback: string = GENERIC_LOAD_MESSAGE) =>
|
||||||
|
pdfErrorMessage(error, fallback);
|
||||||
|
|
||||||
|
/** Shared row/banner message for PDFs that could not be transformed. */
|
||||||
|
export const pdfProcessingErrorMessage = (error: unknown, fallback: string) =>
|
||||||
|
pdfErrorMessage(error, fallback);
|
||||||
+38
-1
@@ -11,13 +11,27 @@ export type OverlayPosition =
|
|||||||
|
|
||||||
export type WatermarkPosition = OverlayPosition | 'diagonal';
|
export type WatermarkPosition = OverlayPosition | 'diagonal';
|
||||||
|
|
||||||
|
/** Position choices shared by every tool that draws an overlay. */
|
||||||
|
export const OVERLAY_POSITION_OPTIONS: { value: OverlayPosition; label: string }[] = [
|
||||||
|
{ value: 'top-left', label: 'Oben links' },
|
||||||
|
{ value: 'top-center', label: 'Oben mittig' },
|
||||||
|
{ value: 'top-right', label: 'Oben rechts' },
|
||||||
|
{ value: 'center', label: 'Zentriert' },
|
||||||
|
{ value: 'bottom-left', label: 'Unten links' },
|
||||||
|
{ value: 'bottom-center', label: 'Unten mittig' },
|
||||||
|
{ value: 'bottom-right', label: 'Unten rechts' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Distance between a mark and the page edge used by the drawing helpers. */
|
||||||
|
export const DEFAULT_OVERLAY_MARGIN = 20;
|
||||||
|
|
||||||
export const getPositionCoords = (
|
export const getPositionCoords = (
|
||||||
pageWidth: number,
|
pageWidth: number,
|
||||||
pageHeight: number,
|
pageHeight: number,
|
||||||
markWidth: number,
|
markWidth: number,
|
||||||
markHeight: number,
|
markHeight: number,
|
||||||
position: WatermarkPosition,
|
position: WatermarkPosition,
|
||||||
margin = 20
|
margin = DEFAULT_OVERLAY_MARGIN
|
||||||
): { x: number; y: number } => {
|
): { x: number; y: number } => {
|
||||||
const horizontallyCentered = (pageWidth - markWidth) / 2;
|
const horizontallyCentered = (pageWidth - markWidth) / 2;
|
||||||
|
|
||||||
@@ -50,3 +64,26 @@ export const parseHexColor = (hexColor: string): RGB => {
|
|||||||
Number.parseInt(match[3], 16) / 255
|
Number.parseInt(match[3], 16) / 255
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Inset used by the HTML previews to approximate the PDF margin at reading size. */
|
||||||
|
export const PREVIEW_INSET = '5%';
|
||||||
|
|
||||||
|
const PREVIEW_POSITION_STYLES: Record<OverlayPosition, Record<string, string>> = {
|
||||||
|
center: { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' },
|
||||||
|
'top-left': { left: PREVIEW_INSET, top: PREVIEW_INSET },
|
||||||
|
'top-center': { left: '50%', top: PREVIEW_INSET, transform: 'translateX(-50%)' },
|
||||||
|
'top-right': { right: PREVIEW_INSET, top: PREVIEW_INSET },
|
||||||
|
'bottom-left': { bottom: PREVIEW_INSET, left: PREVIEW_INSET },
|
||||||
|
'bottom-center': { bottom: PREVIEW_INSET, left: '50%', transform: 'translateX(-50%)' },
|
||||||
|
'bottom-right': { bottom: PREVIEW_INSET, right: PREVIEW_INSET }
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Declarative form, handy for `style:` directives or spreading into a style map. */
|
||||||
|
export const getPreviewPositionStyles = (position: OverlayPosition): Record<string, string> =>
|
||||||
|
PREVIEW_POSITION_STYLES[position];
|
||||||
|
|
||||||
|
/** Same styles as a single inline CSS string, for `style="…"` attributes. */
|
||||||
|
export const getPreviewPositionStyle = (position: OverlayPosition): string =>
|
||||||
|
Object.entries(getPreviewPositionStyles(position))
|
||||||
|
.map(([property, value]) => `${property}: ${value};`)
|
||||||
|
.join(' ');
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
import { parseHexColor, type OverlayPosition } from './pdf-overlay';
|
||||||
|
import { drawTextOverlay } from './pdf-text-overlay';
|
||||||
|
|
||||||
|
export const MIN_START_NUMBER = 1;
|
||||||
|
export const MAX_START_NUMBER = 99999;
|
||||||
|
export const PAGE_NUMBER_MARGIN = 28;
|
||||||
|
export const PAGE_NUMBER_COLOR = '#1a1a1a';
|
||||||
|
export const MIN_FONT_SIZE = 8;
|
||||||
|
export const MAX_FONT_SIZE = 18;
|
||||||
|
/** Longest allowed free text (Aktenzeichen) next to the sheet number. */
|
||||||
|
export const MAX_FREE_TEXT_LENGTH = 60;
|
||||||
|
|
||||||
|
const PAGINATION_PREFIXES = {
|
||||||
|
blatt: 'Bl. ',
|
||||||
|
seite: 'Seite ',
|
||||||
|
s: 'S. ',
|
||||||
|
nummer: ''
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PaginationFormat = keyof typeof PAGINATION_PREFIXES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select options. The label is the rendered first sheet number (`Bl. 1`), so the
|
||||||
|
* UI cannot drift from the PDF text — there is no template syntax involved.
|
||||||
|
*/
|
||||||
|
export const PAGINATION_FORMATS: { value: PaginationFormat; label: string }[] = (
|
||||||
|
Object.keys(PAGINATION_PREFIXES) as PaginationFormat[]
|
||||||
|
).map((value) => ({ value, label: `${PAGINATION_PREFIXES[value]}1`.trim() }));
|
||||||
|
|
||||||
|
export type PaginationTextScope = 'all' | 'first';
|
||||||
|
|
||||||
|
export type PaginationOptions = {
|
||||||
|
startNumber: number;
|
||||||
|
freeText: string;
|
||||||
|
textScope: PaginationTextScope;
|
||||||
|
format: PaginationFormat;
|
||||||
|
position: OverlayPosition;
|
||||||
|
fontSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaginationLabelOptions = Pick<
|
||||||
|
PaginationOptions,
|
||||||
|
'startNumber' | 'freeText' | 'textScope' | 'format'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const normalizeStartNumber = (value: number): number =>
|
||||||
|
Math.min(
|
||||||
|
MAX_START_NUMBER,
|
||||||
|
Math.max(MIN_START_NUMBER, Math.round(Number(value) || MIN_START_NUMBER))
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Trims the free text and enforces `MAX_FREE_TEXT_LENGTH` for programmatic callers. */
|
||||||
|
export const normalizeFreeText = (value: string): string =>
|
||||||
|
value.trim().slice(0, MAX_FREE_TEXT_LENGTH);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheet label for one page: `123 Ja 60/26, Bl. 27`. The number always appears —
|
||||||
|
* it is not user-removable — while the free text only appears on the first page
|
||||||
|
* when that scope is selected.
|
||||||
|
*
|
||||||
|
* Pure formatter: `startNumber` must already be normalised (see `paginatePdf`).
|
||||||
|
*/
|
||||||
|
export const buildPaginationLabel = (
|
||||||
|
pageIndex: number,
|
||||||
|
options: PaginationLabelOptions
|
||||||
|
): string => {
|
||||||
|
const numberText = `${PAGINATION_PREFIXES[options.format]}${options.startNumber + pageIndex}`;
|
||||||
|
const freeText = options.freeText.trim();
|
||||||
|
const showsFreeText = options.textScope === 'all' || pageIndex === 0;
|
||||||
|
|
||||||
|
return showsFreeText && freeText ? `${freeText}, ${numberText}` : numberText;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamps every page with its ascending sheet number. Rotated scans are
|
||||||
|
* compensated so the number stays at the visual position.
|
||||||
|
*
|
||||||
|
* `MIN_FONT_SIZE` is the user's chosen floor: a label that does not fit even at
|
||||||
|
* that size raises a `TextOverlayError` instead of shrinking into illegibility.
|
||||||
|
*/
|
||||||
|
export const paginatePdf = async (
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: PaginationOptions
|
||||||
|
): Promise<Uint8Array> => {
|
||||||
|
const document = await PDFDocument.load(bytes);
|
||||||
|
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
const color = parseHexColor(PAGE_NUMBER_COLOR);
|
||||||
|
// Normalise once at the boundary so the formatter stays pure.
|
||||||
|
const labelOptions: PaginationLabelOptions = {
|
||||||
|
startNumber: normalizeStartNumber(options.startNumber),
|
||||||
|
freeText: normalizeFreeText(options.freeText),
|
||||||
|
textScope: options.textScope,
|
||||||
|
format: options.format
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [pageIndex, page] of document.getPages().entries()) {
|
||||||
|
drawTextOverlay(page, {
|
||||||
|
font,
|
||||||
|
text: buildPaginationLabel(pageIndex, labelOptions),
|
||||||
|
size: options.fontSize,
|
||||||
|
color,
|
||||||
|
position: options.position,
|
||||||
|
margin: PAGE_NUMBER_MARGIN,
|
||||||
|
minSize: MIN_FONT_SIZE,
|
||||||
|
compensatePageRotation: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.save();
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
let measureContext: CanvasRenderingContext2D | null | undefined;
|
||||||
|
|
||||||
|
const getMeasureContext = () => {
|
||||||
|
if (measureContext !== undefined) return measureContext;
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
measureContext = null;
|
||||||
|
return measureContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
measureContext = document.createElement('canvas').getContext('2d');
|
||||||
|
return measureContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rough average advance width per character, used when no canvas is available. */
|
||||||
|
const FALLBACK_WIDTH_RATIO = 0.55;
|
||||||
|
const LINE_HEIGHT_RATIO = 1.2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximate advance width of `text` in PDF points. The PDF standard font is
|
||||||
|
* Helvetica, and Arial/Liberation Sans are metrically compatible, so the
|
||||||
|
* browser's own text measurement is a good stand-in for the export.
|
||||||
|
*/
|
||||||
|
export const measureHelveticaText = (text: string, size: number): number => {
|
||||||
|
if (!text || size <= 0) return 0;
|
||||||
|
|
||||||
|
const context = getMeasureContext();
|
||||||
|
if (!context) return text.length * size * FALLBACK_WIDTH_RATIO;
|
||||||
|
|
||||||
|
context.font = `${size}px Helvetica, Arial, "Liberation Sans", sans-serif`;
|
||||||
|
return context.measureText(text).width;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shrink factor (≤ 1) that the PDF export applies to keep a single line of text
|
||||||
|
* inside the available box. Lets the HTML preview mirror the export instead of
|
||||||
|
* clipping the label.
|
||||||
|
*/
|
||||||
|
export const getTextFitScale = (
|
||||||
|
text: string,
|
||||||
|
size: number,
|
||||||
|
availableWidth: number,
|
||||||
|
availableHeight: number
|
||||||
|
): number => {
|
||||||
|
if (!text || size <= 0 || availableWidth <= 0) return 1;
|
||||||
|
|
||||||
|
const width = measureHelveticaText(text, size);
|
||||||
|
const fitsWidth = width <= availableWidth;
|
||||||
|
const fitsHeight = size * LINE_HEIGHT_RATIO <= availableHeight;
|
||||||
|
|
||||||
|
if (fitsWidth && fitsHeight) return 1;
|
||||||
|
|
||||||
|
const widthScale = width > 0 ? availableWidth / width : 1;
|
||||||
|
const heightScale = availableHeight / (size * LINE_HEIGHT_RATIO);
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(1, widthScale, heightScale));
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { pdfLoadErrorMessage } from './pdf-errors';
|
||||||
|
import { createLatestToken } from './latest-task';
|
||||||
|
|
||||||
|
export type PdfSessionOptions<THandle> = {
|
||||||
|
/** Builds the tool-specific handle (thumbnails, page renderer, …). */
|
||||||
|
load: (file: File, bytes: Uint8Array) => Promise<THandle>;
|
||||||
|
/** Releases a handle that is replaced or cleared. */
|
||||||
|
dispose?: (handle: THandle) => void;
|
||||||
|
/** Resets tool state when a new file is picked (selections, previews, …). */
|
||||||
|
onFileChange?: () => void;
|
||||||
|
/** Fallback message when loading fails for a reason other than encryption. */
|
||||||
|
loadFallbackMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared single-PDF lifecycle: pick a file, read its bytes once, build a handle
|
||||||
|
* and map load errors to German copy. Replaces the per-route copies of
|
||||||
|
* `file`/`bytes`/`handle`/`isLoading`/`loadingError` plus their generation
|
||||||
|
* counters.
|
||||||
|
*
|
||||||
|
* Reads must go through the getters (`session.file`) so Svelte tracks them.
|
||||||
|
*/
|
||||||
|
export const createPdfSession = <THandle>(options: PdfSessionOptions<THandle>) => {
|
||||||
|
let file = $state<File | null>(null);
|
||||||
|
let bytes = $state<Uint8Array | null>(null);
|
||||||
|
let handle = $state<THandle | null>(null);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let loadingError = $state<string | null>(null);
|
||||||
|
const loadToken = createLatestToken();
|
||||||
|
|
||||||
|
const release = () => {
|
||||||
|
if (handle === null) return;
|
||||||
|
options.dispose?.(handle);
|
||||||
|
handle = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async (nextFile: File) => {
|
||||||
|
const ticket = loadToken.begin();
|
||||||
|
|
||||||
|
release();
|
||||||
|
options.onFileChange?.();
|
||||||
|
file = nextFile;
|
||||||
|
bytes = null;
|
||||||
|
loadingError = null;
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextBytes = new Uint8Array(await nextFile.arrayBuffer());
|
||||||
|
const nextHandle = await options.load(nextFile, nextBytes);
|
||||||
|
|
||||||
|
if (!loadToken.isCurrent(ticket)) {
|
||||||
|
options.dispose?.(nextHandle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = nextBytes;
|
||||||
|
handle = nextHandle;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load PDF:', error);
|
||||||
|
if (loadToken.isCurrent(ticket)) {
|
||||||
|
file = null;
|
||||||
|
loadingError = pdfLoadErrorMessage(error, options.loadFallbackMessage);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (loadToken.isCurrent(ticket)) isLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Drops the current file and handle (used on unmount). */
|
||||||
|
const clear = () => {
|
||||||
|
loadToken.invalidate();
|
||||||
|
release();
|
||||||
|
file = null;
|
||||||
|
bytes = null;
|
||||||
|
loadingError = null;
|
||||||
|
isLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
get file() {
|
||||||
|
return file;
|
||||||
|
},
|
||||||
|
get bytes() {
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
get handle() {
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
},
|
||||||
|
get loadingError() {
|
||||||
|
return loadingError;
|
||||||
|
},
|
||||||
|
load,
|
||||||
|
clear
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfSession<THandle> = ReturnType<typeof createPdfSession<THandle>>;
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { degrees, type PDFFont, type PDFImage, type PDFPage, type RGB } from 'pdf-lib';
|
||||||
|
import { TextOverlayError } from './pdf-errors';
|
||||||
|
import { getPositionCoords, type OverlayPosition } from './pdf-overlay';
|
||||||
|
|
||||||
|
/** Default distance between an overlay mark and the page edge, in points. */
|
||||||
|
export const TEXT_OVERLAY_MARGIN = 20;
|
||||||
|
/** Default inner padding of a framed overlay mark, in points. */
|
||||||
|
export const TEXT_OVERLAY_PADDING = 8;
|
||||||
|
/** Default font-size floor while shrinking an overlay to fit the page. */
|
||||||
|
export const MIN_TEXT_OVERLAY_SIZE = 4;
|
||||||
|
export const TEXT_OVERLAY_BORDER_WIDTH = 2;
|
||||||
|
/** Default height floor for image overlays, in points. */
|
||||||
|
export const MIN_IMAGE_OVERLAY_SIZE = 20;
|
||||||
|
|
||||||
|
/** Baseline nudge used by the framed text stamps so glyphs sit inside the border. */
|
||||||
|
const BASELINE_OFFSET_RATIO = 0.2;
|
||||||
|
const DEFAULT_TOO_LONG_MESSAGE =
|
||||||
|
'Der Text ist für mindestens eine Seite zu lang. Kürzen Sie den Text.';
|
||||||
|
const TEXT_NOT_ENCODABLE_MESSAGE =
|
||||||
|
'Der Text enthält Zeichen, die nicht dargestellt werden können. Entfernen Sie z. B. Emoji.';
|
||||||
|
|
||||||
|
export type TextOverlayOptions = {
|
||||||
|
font: PDFFont;
|
||||||
|
text: string;
|
||||||
|
/** Requested font size in points; shrunk down to `minSize` to fit the page. */
|
||||||
|
size: number;
|
||||||
|
color: RGB;
|
||||||
|
/** Where the mark sits on the *displayed* page (see `compensatePageRotation`). */
|
||||||
|
position: OverlayPosition;
|
||||||
|
/** Distance between the mark and the page edge in points. */
|
||||||
|
margin?: number;
|
||||||
|
/** Inner box padding; only used when `framed` is set. */
|
||||||
|
padding?: number;
|
||||||
|
framed?: boolean;
|
||||||
|
borderWidth?: number;
|
||||||
|
/** Additional counter-clockwise rotation of the mark in degrees. */
|
||||||
|
rotation?: number;
|
||||||
|
/** Font size floor while shrinking to fit the page. */
|
||||||
|
minSize?: number;
|
||||||
|
/**
|
||||||
|
* Place the mark on rotated scans (`/Rotate 90`, `180`, `270`) where it is
|
||||||
|
* visible instead of sideways. Opt-in so existing tools keep their output.
|
||||||
|
* When enabled, `position` refers to the displayed page; otherwise it refers
|
||||||
|
* to default user space.
|
||||||
|
*/
|
||||||
|
compensatePageRotation?: boolean;
|
||||||
|
tooLongMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageOverlayOptions = {
|
||||||
|
image: PDFImage;
|
||||||
|
/** Requested image height in points; scaled down (never up) to fit the page. */
|
||||||
|
size: number;
|
||||||
|
color: RGB;
|
||||||
|
position: OverlayPosition;
|
||||||
|
margin?: number;
|
||||||
|
/** Inner box padding; only used when `framed` is set. */
|
||||||
|
padding?: number;
|
||||||
|
framed?: boolean;
|
||||||
|
borderWidth?: number;
|
||||||
|
/** Additional counter-clockwise rotation of the mark in degrees. */
|
||||||
|
rotation?: number;
|
||||||
|
/** Height floor in points; smaller images are drawn at this height. */
|
||||||
|
minSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextBox = {
|
||||||
|
size: number;
|
||||||
|
textHeight: number;
|
||||||
|
boxWidth: number;
|
||||||
|
boxHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureText = (font: PDFFont, text: string, size: number) => {
|
||||||
|
try {
|
||||||
|
return { width: font.widthOfTextAtSize(text, size), height: font.heightAtSize(size) };
|
||||||
|
} catch (error) {
|
||||||
|
// The only expected failure here is a standard font that cannot encode a
|
||||||
|
// character. Anything else is a real bug and must not be reported to the
|
||||||
|
// user as an encoding problem.
|
||||||
|
if (error instanceof Error && /cannot encode|WinAnsi|StandardEncoding/i.test(error.message)) {
|
||||||
|
throw new TextOverlayError(TEXT_NOT_ENCODABLE_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextFitOptions = {
|
||||||
|
font: PDFFont;
|
||||||
|
text: string;
|
||||||
|
requestedSize: number;
|
||||||
|
/** Size of the displayed page after applying `/Rotate`. */
|
||||||
|
visualWidth: number;
|
||||||
|
visualHeight: number;
|
||||||
|
margin: number;
|
||||||
|
padding: number;
|
||||||
|
minSize: number;
|
||||||
|
tooLongMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitTextToPage = ({
|
||||||
|
font,
|
||||||
|
text,
|
||||||
|
requestedSize,
|
||||||
|
visualWidth,
|
||||||
|
visualHeight,
|
||||||
|
margin,
|
||||||
|
padding,
|
||||||
|
minSize,
|
||||||
|
tooLongMessage
|
||||||
|
}: TextFitOptions): TextBox => {
|
||||||
|
const availableWidth = visualWidth - margin * 2;
|
||||||
|
const availableHeight = visualHeight - margin * 2;
|
||||||
|
let size = requestedSize;
|
||||||
|
let metrics = measureText(font, text, size);
|
||||||
|
|
||||||
|
while (
|
||||||
|
size > minSize &&
|
||||||
|
(metrics.width + padding * 2 > availableWidth || metrics.height + padding * 2 > availableHeight)
|
||||||
|
) {
|
||||||
|
size = Math.max(minSize, size - 0.5);
|
||||||
|
metrics = measureText(font, text, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
metrics.width + padding * 2 > availableWidth ||
|
||||||
|
metrics.height + padding * 2 > availableHeight
|
||||||
|
) {
|
||||||
|
throw new TextOverlayError(tooLongMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
size,
|
||||||
|
textHeight: metrics.height,
|
||||||
|
boxWidth: metrics.width + padding * 2,
|
||||||
|
boxHeight: metrics.height + padding * 2
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower-left corner for a (possibly rotated) box inside the given page size.
|
||||||
|
* The bounding box of the rotated mark is aligned to the requested position, so
|
||||||
|
* tilted text never crosses the page edge.
|
||||||
|
*/
|
||||||
|
export const getRotatedBoxPlacement = (
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
boxWidth: number,
|
||||||
|
boxHeight: number,
|
||||||
|
position: OverlayPosition,
|
||||||
|
angle: number,
|
||||||
|
margin = TEXT_OVERLAY_MARGIN
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
const radians = (angle * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(radians);
|
||||||
|
const sine = Math.sin(radians);
|
||||||
|
const rotatedCorners = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: boxWidth * cosine, y: boxWidth * sine },
|
||||||
|
{ x: -boxHeight * sine, y: boxHeight * cosine },
|
||||||
|
{
|
||||||
|
x: boxWidth * cosine - boxHeight * sine,
|
||||||
|
y: boxWidth * sine + boxHeight * cosine
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
|
||||||
|
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
|
||||||
|
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
|
||||||
|
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
|
||||||
|
const boundingCoordinates = getPositionCoords(
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
maxX - minX,
|
||||||
|
maxY - minY,
|
||||||
|
position,
|
||||||
|
margin
|
||||||
|
);
|
||||||
|
|
||||||
|
return { x: boundingCoordinates.x - minX, y: boundingCoordinates.y - minY };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalised page rotation; anything that is not a multiple of 90 is ignored. */
|
||||||
|
const getPageRotation = (page: PDFPage): number => {
|
||||||
|
const angle = Math.round(page.getRotation().angle / 90) * 90;
|
||||||
|
return ((angle % 360) + 360) % 360;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Displayed page size after applying `/Rotate`. */
|
||||||
|
const getVisualPageSize = (pageWidth: number, pageHeight: number, pageRotation: number) =>
|
||||||
|
pageRotation % 180 === 0
|
||||||
|
? { width: pageWidth, height: pageHeight }
|
||||||
|
: { width: pageHeight, height: pageWidth };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a point on the displayed page back into default user space. Viewers
|
||||||
|
* rotate page content clockwise by `/Rotate`, so this is the inverse rotation
|
||||||
|
* that keeps a mark at the requested visual position.
|
||||||
|
*
|
||||||
|
* Invariant (covered by `tests/e2e/unit/pdf-text-overlay.spec.ts`): for every
|
||||||
|
* rotation, `toUserSpace` composed with the clockwise viewer rotation is the
|
||||||
|
* identity, i.e. the mark appears at the requested corner on screen.
|
||||||
|
*/
|
||||||
|
const toUserSpace = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
pageRotation: number
|
||||||
|
) => {
|
||||||
|
switch (pageRotation) {
|
||||||
|
case 90:
|
||||||
|
return { x: pageWidth - point.y, y: point.x };
|
||||||
|
case 180:
|
||||||
|
return { x: pageWidth - point.x, y: pageHeight - point.y };
|
||||||
|
case 270:
|
||||||
|
return { x: point.y, y: pageHeight - point.x };
|
||||||
|
default:
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rotates an offset inside the mark box by `angle` around the box origin. */
|
||||||
|
const rotateOffset = (offsetX: number, offsetY: number, angle: number) => {
|
||||||
|
const radians = (angle * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(radians);
|
||||||
|
const sine = Math.sin(radians);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: offsetX * cosine - offsetY * sine,
|
||||||
|
y: offsetX * sine + offsetY * cosine
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a single line of text (optionally framed) at one of the standard
|
||||||
|
* positions. Shrinks the font to fit the page and throws a `TextOverlayError`
|
||||||
|
* instead of clipping.
|
||||||
|
*/
|
||||||
|
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): void => {
|
||||||
|
const {
|
||||||
|
font,
|
||||||
|
text,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
position,
|
||||||
|
margin = TEXT_OVERLAY_MARGIN,
|
||||||
|
padding = TEXT_OVERLAY_PADDING,
|
||||||
|
framed = false,
|
||||||
|
borderWidth = TEXT_OVERLAY_BORDER_WIDTH,
|
||||||
|
rotation = 0,
|
||||||
|
minSize = MIN_TEXT_OVERLAY_SIZE,
|
||||||
|
compensatePageRotation = false,
|
||||||
|
tooLongMessage = DEFAULT_TOO_LONG_MESSAGE
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const boxPadding = framed ? padding : 0;
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
const pageRotation = compensatePageRotation ? getPageRotation(page) : 0;
|
||||||
|
const visual = getVisualPageSize(width, height, pageRotation);
|
||||||
|
const box = fitTextToPage({
|
||||||
|
font,
|
||||||
|
text,
|
||||||
|
requestedSize: size,
|
||||||
|
visualWidth: visual.width,
|
||||||
|
visualHeight: visual.height,
|
||||||
|
margin,
|
||||||
|
padding: boxPadding,
|
||||||
|
minSize,
|
||||||
|
tooLongMessage
|
||||||
|
});
|
||||||
|
const placement = getRotatedBoxPlacement(
|
||||||
|
visual.width,
|
||||||
|
visual.height,
|
||||||
|
box.boxWidth,
|
||||||
|
box.boxHeight,
|
||||||
|
position,
|
||||||
|
rotation,
|
||||||
|
margin
|
||||||
|
);
|
||||||
|
const origin = toUserSpace(placement, width, height, pageRotation);
|
||||||
|
const angle = rotation + pageRotation;
|
||||||
|
const textOffset = rotateOffset(
|
||||||
|
boxPadding,
|
||||||
|
boxPadding + box.textHeight * BASELINE_OFFSET_RATIO,
|
||||||
|
angle
|
||||||
|
);
|
||||||
|
|
||||||
|
if (framed) {
|
||||||
|
page.drawRectangle({
|
||||||
|
x: origin.x,
|
||||||
|
y: origin.y,
|
||||||
|
width: box.boxWidth,
|
||||||
|
height: box.boxHeight,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page.drawText(text, {
|
||||||
|
x: origin.x + textOffset.x,
|
||||||
|
y: origin.y + textOffset.y,
|
||||||
|
size: box.size,
|
||||||
|
font,
|
||||||
|
color,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws an image (optionally framed) at one of the standard positions, scaled
|
||||||
|
* down to fit the page. Kept next to `drawTextOverlay` so the rotated-placement
|
||||||
|
* maths lives in one place instead of being repeated in a route.
|
||||||
|
*/
|
||||||
|
export const drawImageOverlay = (page: PDFPage, options: ImageOverlayOptions): void => {
|
||||||
|
const {
|
||||||
|
image,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
position,
|
||||||
|
margin = TEXT_OVERLAY_MARGIN,
|
||||||
|
padding = TEXT_OVERLAY_PADDING,
|
||||||
|
framed = false,
|
||||||
|
borderWidth = TEXT_OVERLAY_BORDER_WIDTH,
|
||||||
|
rotation = 0,
|
||||||
|
minSize = MIN_IMAGE_OVERLAY_SIZE
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const boxPadding = framed ? padding : 0;
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
const aspectRatio = image.width / image.height;
|
||||||
|
let drawHeight = size;
|
||||||
|
let drawWidth = drawHeight * aspectRatio;
|
||||||
|
|
||||||
|
const availableWidth = width - margin * 2 - boxPadding * 2;
|
||||||
|
const availableHeight = height - margin * 2 - boxPadding * 2;
|
||||||
|
if (drawWidth > availableWidth || drawHeight > availableHeight) {
|
||||||
|
const scale = Math.min(availableWidth / drawWidth, availableHeight / drawHeight);
|
||||||
|
drawWidth *= scale;
|
||||||
|
drawHeight *= scale;
|
||||||
|
}
|
||||||
|
if (drawHeight < minSize) {
|
||||||
|
drawHeight = minSize;
|
||||||
|
drawWidth = drawHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = getRotatedBoxPlacement(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
drawWidth + boxPadding * 2,
|
||||||
|
drawHeight + boxPadding * 2,
|
||||||
|
position,
|
||||||
|
rotation,
|
||||||
|
margin
|
||||||
|
);
|
||||||
|
|
||||||
|
if (framed) {
|
||||||
|
page.drawRectangle({
|
||||||
|
x: placement.x,
|
||||||
|
y: placement.y,
|
||||||
|
width: drawWidth + boxPadding * 2,
|
||||||
|
height: drawHeight + boxPadding * 2,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth,
|
||||||
|
rotate: degrees(rotation)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageOffset = rotateOffset(boxPadding, boxPadding, rotation);
|
||||||
|
page.drawImage(image, {
|
||||||
|
x: placement.x + imageOffset.x,
|
||||||
|
y: placement.y + imageOffset.y,
|
||||||
|
width: drawWidth,
|
||||||
|
height: drawHeight,
|
||||||
|
rotate: degrees(rotation)
|
||||||
|
});
|
||||||
|
};
|
||||||
+133
-24
@@ -1,17 +1,48 @@
|
|||||||
export type PdfThumbnail = {
|
import type { PDFPageProxy } from 'pdfjs-dist';
|
||||||
|
|
||||||
|
import { createLatestToken } from './latest-task';
|
||||||
|
|
||||||
|
/** One rasterised page, used for thumbnails and for on-demand previews. */
|
||||||
|
export type PdfPageImage = {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Thumbnail grids and on-demand previews share one page-image shape. */
|
||||||
|
export type PdfThumbnail = PdfPageImage;
|
||||||
|
|
||||||
export type PdfDocumentHandle = {
|
export type PdfDocumentHandle = {
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
thumbnails: PdfThumbnail[];
|
thumbnails: PdfPageImage[];
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
|
/** Pixel scale used for on-demand page previews. */
|
||||||
|
export const PDF_PREVIEW_SCALE = 1.25;
|
||||||
|
/** Pixel scale used for the page grid thumbnails. */
|
||||||
|
export const PDF_THUMBNAIL_SCALE = 0.5;
|
||||||
|
|
||||||
|
/** Thrown when a render was superseded by a newer one or by unmounting. */
|
||||||
|
export class PreviewRenderCancelledError extends Error {
|
||||||
|
constructor(message = 'Die PDF-Vorschau wurde zwischenzeitlich geändert.') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PreviewRenderCancelledError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one page at a time. Used by tools that operate on a whole document
|
||||||
|
* (pagination) where rasterising every page up front would exhaust memory.
|
||||||
|
*/
|
||||||
|
export type PdfPageRenderer = {
|
||||||
|
pageCount: number;
|
||||||
|
render: (pageNumber: number) => Promise<PdfPageImage>;
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPdfJs = async () => {
|
||||||
const pdfjsLib = await import('pdfjs-dist');
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
|
||||||
@@ -19,36 +50,55 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
|
|||||||
(module) => module.default
|
(module) => module.default
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return getDocument;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canvasToPngBlob = (canvas: HTMLCanvasElement) =>
|
||||||
|
new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(result) => (result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.'))),
|
||||||
|
'image/png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterises a single page and returns an object URL for the PNG. Shared by the
|
||||||
|
* thumbnail grid and the on-demand preview renderer so both stay in sync.
|
||||||
|
*/
|
||||||
|
const renderPageToPngUrl = async (page: PDFPageProxy, scale: number) => {
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!canvasContext) throw new Error('Could not create a canvas for the PDF page.');
|
||||||
|
|
||||||
|
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageUrl: URL.createObjectURL(await canvasToPngBlob(canvas)),
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
|
||||||
|
const getDocument = await loadPdfJs();
|
||||||
|
|
||||||
|
// pdfjs transfers the buffer to its worker, so it always gets its own copy —
|
||||||
|
// callers keep using `data` for the export.
|
||||||
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||||
const thumbnails: PdfThumbnail[] = [];
|
const thumbnails: PdfPageImage[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdfDocument = await loadingTask.promise;
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
|
||||||
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||||
const page = await pdfDocument.getPage(pageNumber);
|
const page = await pdfDocument.getPage(pageNumber);
|
||||||
const viewport = page.getViewport({ scale: 0.5 });
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = Math.ceil(viewport.width);
|
|
||||||
canvas.height = Math.ceil(viewport.height);
|
|
||||||
const canvasContext = canvas.getContext('2d');
|
|
||||||
|
|
||||||
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
|
|
||||||
|
|
||||||
await page.render({ canvas, canvasContext, viewport }).promise;
|
|
||||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
||||||
canvas.toBlob(
|
|
||||||
(result) =>
|
|
||||||
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
|
|
||||||
'image/png'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
thumbnails.push({
|
thumbnails.push({
|
||||||
pageNumber,
|
pageNumber,
|
||||||
imageUrl: URL.createObjectURL(blob),
|
...(await renderPageToPngUrl(page, PDF_THUMBNAIL_SCALE))
|
||||||
width: viewport.width,
|
|
||||||
height: viewport.height
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,3 +119,62 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadPdfPageRenderer(data: Uint8Array): Promise<PdfPageRenderer> {
|
||||||
|
const getDocument = await loadPdfJs();
|
||||||
|
|
||||||
|
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||||
|
let pdfDocument;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pdfDocument = await loadingTask.promise;
|
||||||
|
} catch (error) {
|
||||||
|
// Mirror `loadPdfDocument`: a failed load must not leave the worker and its
|
||||||
|
// copy of the document data alive.
|
||||||
|
await loadingTask.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let destroyed = false;
|
||||||
|
let currentUrl: string | null = null;
|
||||||
|
const renderToken = createLatestToken();
|
||||||
|
|
||||||
|
const releaseUrl = () => {
|
||||||
|
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
||||||
|
currentUrl = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageCount: pdfDocument.numPages,
|
||||||
|
render: async (pageNumber) => {
|
||||||
|
if (destroyed) throw new PreviewRenderCancelledError('Die PDF-Vorschau wurde geschlossen.');
|
||||||
|
|
||||||
|
const token = renderToken.begin();
|
||||||
|
const clampedPage = Math.min(
|
||||||
|
pdfDocument.numPages,
|
||||||
|
Math.max(1, Math.round(Number(pageNumber) || 1))
|
||||||
|
);
|
||||||
|
const page = await pdfDocument.getPage(clampedPage);
|
||||||
|
const rendered = await renderPageToPngUrl(page, PDF_PREVIEW_SCALE);
|
||||||
|
|
||||||
|
// A newer render (or unmount) superseded this one: drop the bitmap
|
||||||
|
// instead of leaving an orphaned object URL behind.
|
||||||
|
if (destroyed || !renderToken.isCurrent(token)) {
|
||||||
|
URL.revokeObjectURL(rendered.imageUrl);
|
||||||
|
throw new PreviewRenderCancelledError();
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseUrl();
|
||||||
|
currentUrl = rendered.imageUrl;
|
||||||
|
|
||||||
|
return { pageNumber: clampedPage, ...rendered };
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
renderToken.invalidate();
|
||||||
|
releaseUrl();
|
||||||
|
void loadingTask.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,37 @@
|
|||||||
let pendingZipFiles: File[] = [];
|
/** Routes that can receive a handoff from another route. */
|
||||||
|
export type PendingFileTarget = 'bea' | 'pagination';
|
||||||
|
|
||||||
export const queueZipFiles = (files: File[]) => {
|
type QueuedFile = { file: File; target: PendingFileTarget };
|
||||||
pendingZipFiles = [...pendingZipFiles, ...files];
|
|
||||||
|
let queue: QueuedFile[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory handoff between routes (start page → beA workspace, merge →
|
||||||
|
* pagination). Nothing is uploaded or persisted; the receiving route takes and
|
||||||
|
* clears the files queued for its own target on mount.
|
||||||
|
*
|
||||||
|
* The target keeps a queued ZIP from being consumed — and silently dropped — by
|
||||||
|
* a tool that only accepts PDFs.
|
||||||
|
*/
|
||||||
|
export const queuePendingFiles = (files: File[], target: PendingFileTarget) => {
|
||||||
|
queue = [...queue, ...files.map((file) => ({ file, target }))];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const takeQueuedZipFiles = () => {
|
/** Takes (and clears) the files queued for `target`; other targets stay queued. */
|
||||||
const files = pendingZipFiles;
|
export const takePendingFiles = (target: PendingFileTarget): File[] => {
|
||||||
pendingZipFiles = [];
|
const taken: File[] = [];
|
||||||
return files;
|
const remaining: QueuedFile[] = [];
|
||||||
|
|
||||||
|
for (const entry of queue) {
|
||||||
|
if (entry.target === target) taken.push(entry.file);
|
||||||
|
else remaining.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
queue = remaining;
|
||||||
|
return taken;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Test/storybook helper: forget everything that is still queued. */
|
||||||
|
export const clearPendingFiles = () => {
|
||||||
|
queue = [];
|
||||||
};
|
};
|
||||||
@@ -12,6 +12,19 @@ export const tools: Tool[] = [
|
|||||||
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
|
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
|
||||||
icon: 'stamp'
|
icon: 'stamp'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: 'pagination',
|
||||||
|
title: 'Paginieren',
|
||||||
|
description:
|
||||||
|
'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
|
||||||
|
icon: 'list-ordered'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'exhibits',
|
||||||
|
title: 'Anlagen nummerieren',
|
||||||
|
description: 'Nummerieren und benennen Sie Anlagen für die Einreichung — mit Anlagenstempel.',
|
||||||
|
icon: 'files'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: 'watermark',
|
slug: 'watermark',
|
||||||
title: 'Wasserzeichen',
|
title: 'Wasserzeichen',
|
||||||
|
|||||||
+14
-10
@@ -9,6 +9,8 @@
|
|||||||
Scissors,
|
Scissors,
|
||||||
Merge,
|
Merge,
|
||||||
Stamp,
|
Stamp,
|
||||||
|
ListOrdered,
|
||||||
|
Files,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Info,
|
Info,
|
||||||
FileText
|
FileText
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import * as Card from '$lib/components/ui/card/index';
|
import * as Card from '$lib/components/ui/card/index';
|
||||||
import { queueZipFiles } from '$lib/pending-files';
|
import { queuePendingFiles } from '$lib/pending-files';
|
||||||
import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
|
import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
|
||||||
import { tools } from '$lib/tools';
|
import { tools } from '$lib/tools';
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@
|
|||||||
scissors: Scissors,
|
scissors: Scissors,
|
||||||
merge: Merge,
|
merge: Merge,
|
||||||
stamp: Stamp,
|
stamp: Stamp,
|
||||||
|
'list-ordered': ListOrdered,
|
||||||
|
files: Files,
|
||||||
'rotate-cw': RotateCw,
|
'rotate-cw': RotateCw,
|
||||||
info: Info
|
info: Info
|
||||||
};
|
};
|
||||||
@@ -37,7 +41,7 @@
|
|||||||
const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
|
const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
|
||||||
if (workspaceFiles.length === 0) return;
|
if (workspaceFiles.length === 0) return;
|
||||||
|
|
||||||
queueZipFiles(workspaceFiles);
|
queuePendingFiles(workspaceFiles, 'bea');
|
||||||
void goto('/tools/bea');
|
void goto('/tools/bea');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,12 +66,12 @@
|
|||||||
<div class="flex flex-col gap-10 px-4 py-10">
|
<div class="flex flex-col gap-10 px-4 py-10">
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<div class="flex flex-col items-center gap-2 text-center">
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
<h1 class="text-[28px] font-bold text-primary-900">Werkzeugkasten für beA</h1>
|
<h1 class="text-primary-900 text-[28px] font-bold">Werkzeugkasten für beA</h1>
|
||||||
<p class="max-w-lg text-[15px] font-medium text-primary-700">
|
<p class="text-primary-700 max-w-lg text-[15px] font-medium">
|
||||||
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
|
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
|
||||||
praktische PDF-Werkzeuge.
|
praktische PDF-Werkzeuge.
|
||||||
</p>
|
</p>
|
||||||
<p class="max-w-lg text-[13px] text-primary-700">
|
<p class="text-primary-700 max-w-lg text-[13px]">
|
||||||
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
|
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +88,7 @@
|
|||||||
icon={Archive}
|
icon={Archive}
|
||||||
class="py-10"
|
class="py-10"
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-center text-[12px] text-primary-600">
|
<p class="text-primary-600 mt-2 text-center text-[12px]">
|
||||||
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs
|
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs
|
||||||
können direkt in Teildokumente aufgeteilt werden.
|
können direkt in Teildokumente aufgeteilt werden.
|
||||||
</p>
|
</p>
|
||||||
@@ -92,12 +96,12 @@
|
|||||||
|
|
||||||
<!-- Tool grid -->
|
<!-- Tool grid -->
|
||||||
<div class="mx-auto w-full max-w-4xl">
|
<div class="mx-auto w-full max-w-4xl">
|
||||||
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Weitere PDF-Werkzeuge</h2>
|
<h2 class="text-primary-900 mb-4 text-[18px] font-bold">Weitere PDF-Werkzeuge</h2>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each tools as tool}
|
{#each tools as tool}
|
||||||
{@const Icon = iconMap[tool.icon] ?? FileText}
|
{@const Icon = iconMap[tool.icon] ?? FileText}
|
||||||
<a href="/tools/{tool.slug}" class="block">
|
<a href="/tools/{tool.slug}" class="block">
|
||||||
<Card.Root class="h-full transition hover:shadow-md cursor-pointer">
|
<Card.Root class="h-full cursor-pointer transition hover:shadow-md">
|
||||||
<Card.Content class="flex flex-col gap-3 py-6">
|
<Card.Content class="flex flex-col gap-3 py-6">
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
|
||||||
@@ -105,8 +109,8 @@
|
|||||||
<Icon class="h-5 w-5" />
|
<Icon class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3>
|
<h3 class="text-primary-900 text-[15px] font-bold">{tool.title}</h3>
|
||||||
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p>
|
<p class="text-primary-700 mt-1 text-[13px] leading-snug">{tool.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
} from '$lib/zip-processing';
|
} from '$lib/zip-processing';
|
||||||
import { isAcceptedPdfFile } from '$lib/zip-selection';
|
import { isAcceptedPdfFile } from '$lib/zip-selection';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
import { takeQueuedZipFiles } from '$lib/pending-files';
|
import { takePendingFiles } from '$lib/pending-files';
|
||||||
import * as Card from '$lib/components/ui/card/index';
|
import * as Card from '$lib/components/ui/card/index';
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
thumbnailWidthHydrated = true;
|
thumbnailWidthHydrated = true;
|
||||||
|
|
||||||
const queuedFiles = takeQueuedZipFiles();
|
const queuedFiles = takePendingFiles('bea');
|
||||||
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
|
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,6 +201,6 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
|
<h1 class="h1 text-primary-900 text-[20px] font-bold">beA-Edit</h1>
|
||||||
{@render content()}
|
{@render content()}
|
||||||
</div>
|
</div>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
import {
|
import {
|
||||||
compressWithPreset,
|
compressWithPreset,
|
||||||
compressionPresets,
|
compressionPresets,
|
||||||
@@ -12,6 +13,9 @@
|
|||||||
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
|
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
|
||||||
import { AlertTriangle } from '@lucide/svelte';
|
import { AlertTriangle } from '@lucide/svelte';
|
||||||
|
|
||||||
|
const compressErrorMessage = (error: unknown) =>
|
||||||
|
pdfProcessingErrorMessage(error, 'Diese PDF konnte nicht komprimiert werden.');
|
||||||
|
|
||||||
let items = $state<BatchItem[]>([]);
|
let items = $state<BatchItem[]>([]);
|
||||||
let selectedPreset = $state(defaultCompressionPreset.id);
|
let selectedPreset = $state(defaultCompressionPreset.id);
|
||||||
let isRunning = $state(false);
|
let isRunning = $state(false);
|
||||||
@@ -55,7 +59,7 @@
|
|||||||
isRunning = true;
|
isRunning = true;
|
||||||
try {
|
try {
|
||||||
await runBatch(queue, processFile, updateItem, {
|
await runBatch(queue, processFile, updateItem, {
|
||||||
errorMessage: 'Diese PDF konnte nicht komprimiert werden.',
|
errorMessage: compressErrorMessage,
|
||||||
// Skip rows removed while they were still waiting in the queue.
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
shouldProcess: (id) => items.some((item) => item.id === id)
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
});
|
});
|
||||||
@@ -72,7 +76,7 @@
|
|||||||
isRunning = true;
|
isRunning = true;
|
||||||
try {
|
try {
|
||||||
await runBatch([item], processFile, updateItem, {
|
await runBatch([item], processFile, updateItem, {
|
||||||
errorMessage: 'Diese PDF konnte nicht komprimiert werden.'
|
errorMessage: compressErrorMessage
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isRunning = false;
|
isRunning = false;
|
||||||
@@ -97,8 +101,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
<div class="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
||||||
<div class="text-sm text-amber-800">
|
<div class="text-sm text-amber-800">
|
||||||
<p class="font-medium">Hinweis zur Qualität</p>
|
<p class="font-medium">Hinweis zur Qualität</p>
|
||||||
<p class="mt-1">
|
<p class="mt-1">
|
||||||
@@ -118,17 +122,17 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||||
<h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
|
<h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
|
||||||
<p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
|
<p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each compressionPresets as preset (preset.id)}
|
{#each compressionPresets as preset (preset.id)}
|
||||||
<label
|
<label
|
||||||
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
|
class="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition {selectedPreset ===
|
||||||
preset.id
|
preset.id
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-border hover:border-primary-300'}"
|
: 'hover:border-primary-300 border-border'}"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { downloadBlob, downloadBlobUrl } from '$lib/download';
|
import { downloadBlob, downloadBlobUrl } from '$lib/download';
|
||||||
|
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
||||||
import { zipSync } from 'fflate';
|
import { zipSync } from 'fflate';
|
||||||
import { Image } from '@lucide/svelte';
|
import { Image } from '@lucide/svelte';
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
} catch (conversionError) {
|
} catch (conversionError) {
|
||||||
console.error('Conversion failed:', conversionError);
|
console.error('Conversion failed:', conversionError);
|
||||||
error = 'Fehler beim Konvertieren des PDFs.';
|
error = pdfProcessingErrorMessage(conversionError, 'Fehler beim Konvertieren des PDFs.');
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
@@ -101,8 +103,8 @@
|
|||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-primary-900">Konvertieren</h1>
|
<h1 class="text-primary-900 text-2xl font-bold">Konvertieren</h1>
|
||||||
<p class="mt-2 text-sm text-primary-700">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
|
<p class="text-primary-700 mt-2 text-sm">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !pdfFile}
|
{#if !pdfFile}
|
||||||
@@ -116,28 +118,28 @@
|
|||||||
class="py-4"
|
class="py-4"
|
||||||
/>
|
/>
|
||||||
<RasterizationWarning file={pdfFile} />
|
<RasterizationWarning file={pdfFile} />
|
||||||
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
|
<div class="border-primary-200 space-y-4 rounded-xl border bg-white p-4">
|
||||||
<div class="flex items-center gap-2 text-primary-900">
|
<div class="text-primary-900 flex items-center gap-2">
|
||||||
<Image class="h-5 w-5" />
|
<Image class="h-5 w-5" />
|
||||||
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
|
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-primary-900 mb-2">Bildformat</p>
|
<p class="text-primary-900 mb-2 text-sm font-medium">Bildformat</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
|
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
|
||||||
<span class="text-sm text-primary-700">PNG (verlustfrei)</span>
|
<span class="text-primary-700 text-sm">PNG (verlustfrei)</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
|
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
|
||||||
<span class="text-sm text-primary-700">JPEG (kleiner)</span>
|
<span class="text-primary-700 text-sm">JPEG (kleiner)</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-primary-900 mb-1" for="render-scale">
|
<label class="text-primary-900 mb-1 block text-sm font-medium" for="render-scale">
|
||||||
Auflösung: {scale}x
|
Auflösung: {scale}x
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -149,7 +151,7 @@
|
|||||||
bind:value={scale}
|
bind:value={scale}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-primary-600 mt-1">
|
<p class="text-primary-600 mt-1 text-xs">
|
||||||
{scale === 1
|
{scale === 1
|
||||||
? '~72 DPI'
|
? '~72 DPI'
|
||||||
: scale === 2
|
: scale === 2
|
||||||
@@ -162,11 +164,11 @@
|
|||||||
|
|
||||||
{#if isProcessing && totalPages > 0}
|
{#if isProcessing && totalPages > 0}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex justify-between text-xs text-primary-700">
|
<div class="text-primary-700 flex justify-between text-xs">
|
||||||
<span>Wird konvertiert...</span>
|
<span>Wird konvertiert...</span>
|
||||||
<span>{progress} / {totalPages}</span>
|
<span>{progress} / {totalPages}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full rounded-full bg-primary-100">
|
<div class="bg-primary-100 h-2 w-full rounded-full">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full bg-primary transition-all"
|
class="h-2 rounded-full bg-primary transition-all"
|
||||||
style="width: {(progress / totalPages) * 100}%"
|
style="width: {(progress / totalPages) * 100}%"
|
||||||
@@ -176,11 +178,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
<ToolError message={error} />
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
|
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
@@ -142,8 +143,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
<div class="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
||||||
<div class="text-sm text-amber-800">
|
<div class="text-sm text-amber-800">
|
||||||
<p class="font-medium">Hinweis zur Qualität</p>
|
<p class="font-medium">Hinweis zur Qualität</p>
|
||||||
<p class="mt-1">
|
<p class="mt-1">
|
||||||
@@ -163,7 +164,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||||
<div class="flex items-center gap-2 text-foreground">
|
<div class="flex items-center gap-2 text-foreground">
|
||||||
<Unlock class="h-5 w-5" />
|
<Unlock class="h-5 w-5" />
|
||||||
<h2 class="text-lg font-semibold">Passwort</h2>
|
<h2 class="text-lg font-semibold">Passwort</h2>
|
||||||
@@ -171,14 +172,14 @@
|
|||||||
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-foreground mb-1" for="decrypt-password">
|
<label class="mb-1 block text-sm font-medium text-foreground" for="decrypt-password">
|
||||||
Passwort
|
Passwort
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="decrypt-password"
|
id="decrypt-password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
|
||||||
placeholder="Passwort der PDFs eingeben"
|
placeholder="Passwort der PDFs eingeben"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,11 +200,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
<ToolError message={error} />
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||||
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||||
<div class="flex items-center gap-2 text-foreground">
|
<div class="flex items-center gap-2 text-foreground">
|
||||||
<Lock class="h-5 w-5" />
|
<Lock class="h-5 w-5" />
|
||||||
<h2 class="text-lg font-semibold">Passwort</h2>
|
<h2 class="text-lg font-semibold">Passwort</h2>
|
||||||
@@ -120,21 +121,21 @@
|
|||||||
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-foreground mb-1" for="encrypt-password">
|
<label class="mb-1 block text-sm font-medium text-foreground" for="encrypt-password">
|
||||||
Passwort
|
Passwort
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="encrypt-password"
|
id="encrypt-password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
|
||||||
placeholder="Passwort eingeben"
|
placeholder="Passwort eingeben"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-foreground mb-1"
|
class="mb-1 block text-sm font-medium text-foreground"
|
||||||
for="encrypt-password-confirmation"
|
for="encrypt-password-confirmation"
|
||||||
>
|
>
|
||||||
Passwort bestätigen
|
Passwort bestätigen
|
||||||
@@ -143,7 +144,7 @@
|
|||||||
id="encrypt-password-confirmation"
|
id="encrypt-password-confirmation"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
|
||||||
placeholder="Passwort wiederholen"
|
placeholder="Passwort wiederholen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,11 +177,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
<ToolError message={error} />
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { Files } from '@lucide/svelte';
|
||||||
|
import { PDFDocument, StandardFonts, type RGB } from 'pdf-lib';
|
||||||
|
import ExhibitFileList from '$lib/components/ExhibitFileList.svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import {
|
||||||
|
DEFAULT_EXHIBIT_PREFIX,
|
||||||
|
MAX_EXHIBIT_PREFIX_LENGTH,
|
||||||
|
MAX_EXHIBIT_START_NUMBER,
|
||||||
|
MIN_EXHIBIT_START_NUMBER,
|
||||||
|
buildExhibitFileName,
|
||||||
|
buildExhibitLabel,
|
||||||
|
normalizeExhibitPrefix,
|
||||||
|
normalizeExhibitStartNumber
|
||||||
|
} from '$lib/exhibit-naming';
|
||||||
|
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
|
import {
|
||||||
|
OVERLAY_POSITION_OPTIONS,
|
||||||
|
getPreviewPositionStyle,
|
||||||
|
parseHexColor,
|
||||||
|
type OverlayPosition
|
||||||
|
} from '$lib/pdf-overlay';
|
||||||
|
import { createPdfSession } from '$lib/pdf-session.svelte';
|
||||||
|
import {
|
||||||
|
MIN_TEXT_OVERLAY_SIZE,
|
||||||
|
TEXT_OVERLAY_MARGIN,
|
||||||
|
drawTextOverlay
|
||||||
|
} from '$lib/pdf-text-overlay';
|
||||||
|
import { getTextFitScale } from '$lib/pdf-preview-text';
|
||||||
|
import {
|
||||||
|
PDF_PREVIEW_SCALE,
|
||||||
|
PreviewRenderCancelledError,
|
||||||
|
loadPdfPageRenderer,
|
||||||
|
type PdfPageImage,
|
||||||
|
type PdfPageRenderer
|
||||||
|
} from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
|
const PRESETS = ['Anlage', 'Anlage K', 'Anlage B', 'Anlage XYZ'] as const;
|
||||||
|
/** Inner padding of the framed exhibit stamp, in points. */
|
||||||
|
const EXHIBIT_PADDING = 6;
|
||||||
|
const EXHIBIT_TOO_LONG_MESSAGE =
|
||||||
|
'Die Anlagen-Bezeichnung ist für mindestens eine Seite zu lang. Kürzen Sie die Bezeichnung.';
|
||||||
|
|
||||||
|
/** Number and label frozen when a row is created, so results stay valid. */
|
||||||
|
type Assignment = { number: number; label: string };
|
||||||
|
|
||||||
|
type RunOptions = {
|
||||||
|
prefix: string;
|
||||||
|
fontSize: number;
|
||||||
|
color: RGB;
|
||||||
|
position: OverlayPosition;
|
||||||
|
everyPage: boolean;
|
||||||
|
rename: boolean;
|
||||||
|
highestNumber: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = $state<BatchItem[]>([]);
|
||||||
|
let prefix = $state(DEFAULT_EXHIBIT_PREFIX);
|
||||||
|
let startNumberInput = $state<number | undefined>(1);
|
||||||
|
let everyPage = $state(false);
|
||||||
|
let position = $state<OverlayPosition>('top-right');
|
||||||
|
let fontSize = $state(14);
|
||||||
|
let stampColor = $state('#1a1a1a');
|
||||||
|
let rename = $state(true);
|
||||||
|
let assignments = $state(new Map<string, Assignment>());
|
||||||
|
let selectedId = $state<string | null>(null);
|
||||||
|
let isRunning = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let preview = $state<PdfPageImage | null>(null);
|
||||||
|
let previewError = $state<string | null>(null);
|
||||||
|
let previewRetry = $state(0);
|
||||||
|
|
||||||
|
const session = createPdfSession<PdfPageRenderer>({
|
||||||
|
load: (_file, bytes) => loadPdfPageRenderer(bytes),
|
||||||
|
dispose: (pageRenderer) => pageRenderer.destroy(),
|
||||||
|
onFileChange: () => {
|
||||||
|
preview = null;
|
||||||
|
previewError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const startNumber = $derived(normalizeExhibitStartNumber(startNumberInput));
|
||||||
|
const effectivePrefix = $derived(normalizeExhibitPrefix(prefix));
|
||||||
|
const prefixMissing = $derived(effectivePrefix.length === 0);
|
||||||
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
|
const canRun = $derived(!isRunning && pendingCount > 0 && !prefixMissing);
|
||||||
|
const hasResults = $derived(items.some((item) => item.status === 'done'));
|
||||||
|
|
||||||
|
const numberFor = (item: BatchItem): number => {
|
||||||
|
const frozen = assignments.get(item.id);
|
||||||
|
if (frozen) return frozen.number;
|
||||||
|
|
||||||
|
const index = items.findIndex((entry) => entry.id === item.id);
|
||||||
|
return startNumber + Math.max(0, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelFor = (item: BatchItem): string =>
|
||||||
|
assignments.get(item.id)?.label ?? buildExhibitLabel(effectivePrefix, numberFor(item));
|
||||||
|
|
||||||
|
const highestNumber = $derived(
|
||||||
|
items.reduce(
|
||||||
|
(highest, item) => Math.max(highest, numberFor(item)),
|
||||||
|
startNumber + Math.max(0, items.length - 1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Freezes number and label once, so reordering never rewrites a result. */
|
||||||
|
/** Freezes number and label once, so reordering never rewrites a result. */
|
||||||
|
const freezeAssignment = (item: BatchItem): Assignment => {
|
||||||
|
const existing = assignments.get(item.id);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const number = numberFor(item);
|
||||||
|
const assignment = { number, label: buildExhibitLabel(effectivePrefix, number) };
|
||||||
|
assignments = new Map(assignments).set(item.id, assignment);
|
||||||
|
return assignment;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentFor = (item: BatchItem): Assignment =>
|
||||||
|
assignments.get(item.id) ?? freezeAssignment(item);
|
||||||
|
|
||||||
|
const labels = $derived(
|
||||||
|
Object.fromEntries(items.map((item) => [item.id, labelFor(item)])) as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileNames = $derived(
|
||||||
|
Object.fromEntries(
|
||||||
|
items.map((item) => [
|
||||||
|
item.id,
|
||||||
|
rename
|
||||||
|
? buildExhibitFileName({
|
||||||
|
prefix: effectivePrefix,
|
||||||
|
number: numberFor(item),
|
||||||
|
highestNumber,
|
||||||
|
originalName: item.file.name
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
])
|
||||||
|
) as Record<string, string | null>
|
||||||
|
);
|
||||||
|
|
||||||
|
const numberExample = $derived(
|
||||||
|
Array.from({ length: Math.min(3, items.length) }, (_, index) => startNumber + index).join(', ')
|
||||||
|
);
|
||||||
|
const prefixExample = $derived(
|
||||||
|
buildExhibitLabel(effectivePrefix || DEFAULT_EXHIBIT_PREFIX, startNumber)
|
||||||
|
);
|
||||||
|
const renameExample = $derived(
|
||||||
|
buildExhibitFileName({
|
||||||
|
prefix: effectivePrefix || DEFAULT_EXHIBIT_PREFIX,
|
||||||
|
number: startNumber,
|
||||||
|
highestNumber,
|
||||||
|
originalName: 'Klageschrift.pdf'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const selectedLabel = $derived(selectedId ? (labels[selectedId] ?? '') : '');
|
||||||
|
const selectedPageCount = $derived(session.handle?.pageCount ?? 0);
|
||||||
|
|
||||||
|
const previewFit = $derived.by(() => {
|
||||||
|
if (!preview) return { scale: 1, shrunk: false, tooLong: false };
|
||||||
|
|
||||||
|
const pageWidth = preview.width / PDF_PREVIEW_SCALE;
|
||||||
|
const pageHeight = preview.height / PDF_PREVIEW_SCALE;
|
||||||
|
const raw = getTextFitScale(
|
||||||
|
selectedLabel,
|
||||||
|
fontSize,
|
||||||
|
pageWidth - TEXT_OVERLAY_MARGIN * 2 - EXHIBIT_PADDING * 2,
|
||||||
|
pageHeight - TEXT_OVERLAY_MARGIN * 2 - EXHIBIT_PADDING * 2
|
||||||
|
);
|
||||||
|
const minScale = MIN_TEXT_OVERLAY_SIZE / fontSize;
|
||||||
|
|
||||||
|
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Font size in container-query units so the preview scales like the export. */
|
||||||
|
const previewFontSize = $derived(
|
||||||
|
preview ? ((fontSize * previewFit.scale) / (preview.width / PDF_PREVIEW_SCALE)) * 100 : 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
|
const additions = files
|
||||||
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
|
.map((file) => createBatchItem(file));
|
||||||
|
if (additions.length === 0) return;
|
||||||
|
|
||||||
|
items = [...items, ...additions];
|
||||||
|
error = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveItem = (from: number, to: number) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) return;
|
||||||
|
|
||||||
|
const next = [...items];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
items = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectItem = (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
|
||||||
|
if (selectedId === id) {
|
||||||
|
selectedId = null;
|
||||||
|
session.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
selectedId = id;
|
||||||
|
void session.load(item.file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentRunOptions = (): RunOptions => ({
|
||||||
|
prefix: effectivePrefix,
|
||||||
|
fontSize: Number(fontSize),
|
||||||
|
color: parseHexColor(stampColor),
|
||||||
|
position,
|
||||||
|
everyPage,
|
||||||
|
rename,
|
||||||
|
highestNumber
|
||||||
|
});
|
||||||
|
|
||||||
|
const processFile = async (file: File, item: BatchItem, options: RunOptions) => {
|
||||||
|
const assignment = assignmentFor(item);
|
||||||
|
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const targets = options.everyPage ? pages : pages.slice(0, 1);
|
||||||
|
|
||||||
|
for (const page of targets) {
|
||||||
|
drawTextOverlay(page, {
|
||||||
|
font,
|
||||||
|
text: assignment.label,
|
||||||
|
size: options.fontSize,
|
||||||
|
color: options.color,
|
||||||
|
position: options.position,
|
||||||
|
margin: TEXT_OVERLAY_MARGIN,
|
||||||
|
padding: EXHIBIT_PADDING,
|
||||||
|
framed: true,
|
||||||
|
minSize: MIN_TEXT_OVERLAY_SIZE,
|
||||||
|
compensatePageRotation: true,
|
||||||
|
tooLongMessage: EXHIBIT_TOO_LONG_MESSAGE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await pdfDoc.save();
|
||||||
|
const name = options.rename
|
||||||
|
? buildExhibitFileName({
|
||||||
|
prefix: options.prefix,
|
||||||
|
number: assignment.number,
|
||||||
|
highestNumber: options.highestNumber,
|
||||||
|
originalName: file.name
|
||||||
|
})
|
||||||
|
: file.name;
|
||||||
|
|
||||||
|
return { bytes, name };
|
||||||
|
};
|
||||||
|
|
||||||
|
const exhibitErrorMessage = (processingError: unknown) =>
|
||||||
|
pdfProcessingErrorMessage(
|
||||||
|
processingError,
|
||||||
|
'Diese PDF konnte nicht als Anlage erstellt werden. Prüfen Sie die Datei.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning || !canRun) return;
|
||||||
|
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
// Freeze every number before the first file is processed: reordering or
|
||||||
|
// changing the start number afterwards must not rewrite a result.
|
||||||
|
for (const item of queue) freezeAssignment(item);
|
||||||
|
|
||||||
|
const options = currentRunOptions();
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, (file, item) => processFile(file, item, options), updateItem, {
|
||||||
|
errorMessage: exhibitErrorMessage,
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const options = currentRunOptions();
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], (file, entry) => processFile(file, entry, options), updateItem, {
|
||||||
|
errorMessage: exhibitErrorMessage
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
|
||||||
|
if (selectedId === id) {
|
||||||
|
selectedId = null;
|
||||||
|
session.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeRenderer = session.handle;
|
||||||
|
void previewRetry;
|
||||||
|
if (!activeRenderer) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void activeRenderer
|
||||||
|
.render(1)
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
preview = result;
|
||||||
|
previewError = null;
|
||||||
|
})
|
||||||
|
.catch((renderError) => {
|
||||||
|
if (cancelled || renderError instanceof PreviewRenderCancelledError) return;
|
||||||
|
console.error('Failed to render exhibit preview:', renderError);
|
||||||
|
preview = null;
|
||||||
|
previewError = 'Die Vorschau dieser Anlage konnte nicht erzeugt werden.';
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
session.clear();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Anlagen nummerieren — beA-Edit</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-5xl flex-col gap-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-secondary">
|
||||||
|
<Files class="size-5" aria-hidden="true" />
|
||||||
|
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Anlagenbezeichnung</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Anlagen nummerieren</h1>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm text-primary">
|
||||||
|
Versehen Sie mehrere PDFs mit fortlaufenden Anlagen-Bezeichnungen und benennen Sie die Dateien
|
||||||
|
passend um.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
multiple
|
||||||
|
label={items.length === 0 ? 'PDFs als Anlagen hinzufügen' : 'Weitere PDFs hinzufügen'}
|
||||||
|
sublabel={items.length === 0
|
||||||
|
? 'PDFs hier ablegen oder auswählen — eine Datei entspricht einer Anlage'
|
||||||
|
: 'Weitere Anlagen hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="grid gap-6 {selectedId ? 'lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]' : ''}">
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="exhibit-options-title"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 id="exhibit-options-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Anlagen einstellen
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Eine Datei entspricht einer Anlage. Die Einstellungen gelten für alle Dateien der Liste.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="text-sm font-medium text-foreground">Bezeichnung</legend>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each PRESETS as preset}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-bold tracking-wide transition focus:outline-none focus-visible:ring-2 focus-visible:ring-ring {normalizeExhibitPrefix(
|
||||||
|
prefix
|
||||||
|
) === preset
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border bg-background text-foreground hover:border-primary/40'}"
|
||||||
|
aria-pressed={normalizeExhibitPrefix(prefix) === preset}
|
||||||
|
onclick={() => (prefix = preset)}
|
||||||
|
>
|
||||||
|
{preset}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="exhibit-prefix">
|
||||||
|
Bezeichnung (frei wählbar)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="exhibit-prefix"
|
||||||
|
type="text"
|
||||||
|
bind:value={prefix}
|
||||||
|
maxlength={MAX_EXHIBIT_PREFIX_LENGTH}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
{#if prefixMissing}
|
||||||
|
<p class="text-xs text-destructive" role="status">
|
||||||
|
Bitte geben Sie eine Bezeichnung an, z. B. „Anlage K“.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground" role="status">
|
||||||
|
Beispiel: <span class="font-semibold">{prefixExample}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="exhibit-start-number">
|
||||||
|
Startnummer
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="exhibit-start-number"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
step="1"
|
||||||
|
min={MIN_EXHIBIT_START_NUMBER}
|
||||||
|
max={MAX_EXHIBIT_START_NUMBER}
|
||||||
|
bind:value={startNumberInput}
|
||||||
|
onchange={() => (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}
|
||||||
|
<p class="text-xs text-muted-foreground sm:col-span-2" role="note">
|
||||||
|
{items.length} Datei{items.length === 1 ? '' : 'en'} → {effectivePrefix ||
|
||||||
|
DEFAULT_EXHIBIT_PREFIX}
|
||||||
|
{numberExample}{items.length > 3 ? ', …' : ''}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="exhibit-every-page">
|
||||||
|
<input id="exhibit-every-page" type="checkbox" bind:checked={everyPage} />
|
||||||
|
Auf jeder Seite stempeln
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="exhibit-position">
|
||||||
|
Position
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="exhibit-position"
|
||||||
|
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)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="exhibit-color">Farbe</label>
|
||||||
|
<input
|
||||||
|
id="exhibit-color"
|
||||||
|
type="color"
|
||||||
|
bind:value={stampColor}
|
||||||
|
class="h-10 w-full cursor-pointer rounded-lg border border-input bg-background p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="exhibit-font-size">
|
||||||
|
Schriftgröße: {fontSize} pt
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="exhibit-font-size"
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="24"
|
||||||
|
bind:value={fontSize}
|
||||||
|
aria-valuetext="{fontSize} Punkt"
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 border-t border-border pt-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="exhibit-rename">
|
||||||
|
<input id="exhibit-rename" type="checkbox" bind:checked={rename} />
|
||||||
|
Dateien umbenennen
|
||||||
|
</label>
|
||||||
|
{#if rename}
|
||||||
|
<p class="text-xs text-muted-foreground" role="note">
|
||||||
|
Muster: <code class="rounded bg-muted px-1 py-0.5">{renameExample}</code>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isRunning || hasResults}
|
||||||
|
<p class="text-xs text-primary" role="note">
|
||||||
|
Bereits erstellte Anlagen behalten ihre Nummer.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if selectedId}
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="exhibit-preview-title"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="exhibit-preview-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Vorschau
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{selectedLabel} · Seite 1 von {selectedPageCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if previewError}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-foreground">{previewError}</p>
|
||||||
|
<Button variant="tertiary" onclick={() => (previewRetry += 1)}>
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else if preview}
|
||||||
|
<div
|
||||||
|
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||||
|
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
|
||||||
|
>
|
||||||
|
<img src={preview.imageUrl} alt="Vorschau von Seite 1" class="block h-full w-full" />
|
||||||
|
<div class="absolute" style={getPreviewPositionStyle(position)}>
|
||||||
|
<div
|
||||||
|
class="border-2 font-sans leading-tight font-bold whitespace-nowrap"
|
||||||
|
style="border-color: {stampColor}; color: {stampColor}; padding: 0.35em; font-size: {previewFontSize}cqw;"
|
||||||
|
data-testid="exhibit-preview"
|
||||||
|
data-position={position}
|
||||||
|
>
|
||||||
|
{selectedLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Vorschau wird geladen …
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if previewFit.tooLong}
|
||||||
|
<p class="text-xs text-destructive" role="status">
|
||||||
|
Die Bezeichnung ist für diese Seite zu lang. Kürzen Sie sie.
|
||||||
|
</p>
|
||||||
|
{:else if previewFit.shrunk}
|
||||||
|
<p class="text-xs text-muted-foreground" role="status">
|
||||||
|
Auf dieser Seite wird die Bezeichnung verkleinert.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExhibitFileList
|
||||||
|
{items}
|
||||||
|
{labels}
|
||||||
|
{fileNames}
|
||||||
|
{selectedId}
|
||||||
|
zipBaseName="anlagen"
|
||||||
|
disabled={isRunning}
|
||||||
|
onSelect={selectItem}
|
||||||
|
onMove={moveItem}
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end gap-3">
|
||||||
|
<ToolError message={error} class="w-full" />
|
||||||
|
<Button size="large" onclick={startBatch} disabled={!canRun}>
|
||||||
|
{isRunning ? 'Anlagen werden erstellt …' : 'Anlagen erstellen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ToolError message={error} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { goto } from '$app/navigation';
|
||||||
|
import { downloadBlobFile, pdfBytesToBlob } from '$lib/download';
|
||||||
|
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
|
import { queuePendingFiles } from '$lib/pending-files';
|
||||||
import { mergePdfFiles } from '$lib/pdf-processing';
|
import { mergePdfFiles } from '$lib/pdf-processing';
|
||||||
import { Trash2 } from '@lucide/svelte';
|
import { Trash2 } from '@lucide/svelte';
|
||||||
|
|
||||||
|
type MergeAction = 'download' | 'paginate';
|
||||||
|
|
||||||
let pdfFiles: File[] = $state([]);
|
let pdfFiles: File[] = $state([]);
|
||||||
let isProcessing = $state(false);
|
let activeAction = $state<MergeAction | null>(null);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const isProcessing = $derived(activeAction !== null);
|
||||||
|
|
||||||
const handleFilesSelected = (files: File[]) => {
|
const handleFilesSelected = (files: File[]) => {
|
||||||
pdfFiles = [...pdfFiles, ...files];
|
pdfFiles = [...pdfFiles, ...files];
|
||||||
error = null;
|
error = null;
|
||||||
@@ -26,27 +34,37 @@
|
|||||||
pdfFiles = newFiles;
|
pdfFiles = newFiles;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergePdfs = async () => {
|
const runMerge = async (action: MergeAction) => {
|
||||||
if (pdfFiles.length < 2) return;
|
if (pdfFiles.length < 2 || activeAction) return;
|
||||||
|
|
||||||
isProcessing = true;
|
activeAction = action;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
downloadBlob(await mergePdfFiles(pdfFiles), 'zusammengefuegt.pdf');
|
const merged = pdfBytesToBlob(await mergePdfFiles(pdfFiles));
|
||||||
|
|
||||||
|
if (action === 'paginate') {
|
||||||
|
queuePendingFiles(
|
||||||
|
[new File([merged], 'zusammengefuegt.pdf', { type: 'application/pdf' })],
|
||||||
|
'pagination'
|
||||||
|
);
|
||||||
|
await goto('/tools/pagination');
|
||||||
|
} else {
|
||||||
|
downloadBlobFile(merged, 'zusammengefuegt.pdf');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Merge failed:', err);
|
console.error('Merge failed:', err);
|
||||||
error = 'Fehler beim Zusammenfügen der PDFs.';
|
error = pdfProcessingErrorMessage(err, 'Fehler beim Zusammenfügen der PDFs.');
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
activeAction = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1>
|
<h1 class="text-primary-900 text-2xl font-bold">PDFs zusammenfügen</h1>
|
||||||
<p class="mt-2 text-sm text-primary-700">
|
<p class="text-primary-700 mt-2 text-sm">
|
||||||
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
|
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,30 +73,38 @@
|
|||||||
|
|
||||||
{#if pdfFiles.length > 0}
|
{#if pdfFiles.length > 0}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h2 class="text-lg font-semibold text-primary-900">
|
<h2 class="text-primary-900 text-lg font-semibold">
|
||||||
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
|
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
|
||||||
</h2>
|
</h2>
|
||||||
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
|
<Button
|
||||||
</Button>
|
variant="tertiary"
|
||||||
|
onclick={() => runMerge('paginate')}
|
||||||
|
disabled={isProcessing || pdfFiles.length < 2}
|
||||||
|
>
|
||||||
|
{activeAction === 'paginate' ? 'Wird zusammengefügt …' : 'Zusammenfügen & paginieren'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onclick={() => runMerge('download')}
|
||||||
|
disabled={isProcessing || pdfFiles.length < 2}
|
||||||
|
>
|
||||||
|
{activeAction === 'download' ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
<ToolError message={error} />
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each pdfFiles as file, index}
|
{#each pdfFiles as file, index}
|
||||||
<div class="flex items-center gap-3 rounded-lg border border-primary-200 bg-white p-3">
|
<div class="border-primary-200 flex items-center gap-3 rounded-lg border bg-white p-3">
|
||||||
<span
|
<span
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-1 truncate text-sm text-primary-900">{file.name}</span>
|
<span class="text-primary-900 flex-1 truncate text-sm">{file.name}</span>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { formatBytes } from '$lib/batch';
|
import { formatBytes } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
import { readPdfMetadata, stripPdfMetadata, type PdfMetadataResult } from '$lib/pdf-metadata';
|
import { readPdfMetadata, stripPdfMetadata, type PdfMetadataResult } from '$lib/pdf-metadata';
|
||||||
import { AlertTriangle, Info, Lock } from '@lucide/svelte';
|
import { AlertTriangle, Info, Lock } from '@lucide/svelte';
|
||||||
|
|
||||||
@@ -25,9 +27,12 @@
|
|||||||
try {
|
try {
|
||||||
const bytes = new Uint8Array(await selected.arrayBuffer());
|
const bytes = new Uint8Array(await selected.arrayBuffer());
|
||||||
result = await readPdfMetadata(bytes);
|
result = await readPdfMetadata(bytes);
|
||||||
} catch {
|
} catch (loadError) {
|
||||||
file = null;
|
file = null;
|
||||||
error = 'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.';
|
error = pdfLoadErrorMessage(
|
||||||
|
loadError,
|
||||||
|
'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
isReading = false;
|
isReading = false;
|
||||||
}
|
}
|
||||||
@@ -41,8 +46,11 @@
|
|||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
const cleaned = await stripPdfMetadata(bytes);
|
const cleaned = await stripPdfMetadata(bytes);
|
||||||
downloadBlob(cleaned, cleanedFileName);
|
downloadBlob(cleaned, cleanedFileName);
|
||||||
} catch {
|
} catch (cleanError) {
|
||||||
error = 'Die Metadaten konnten nicht entfernt werden. Bitte versuchen Sie es erneut.';
|
error = pdfProcessingErrorMessage(
|
||||||
|
cleanError,
|
||||||
|
'Die Metadaten konnten nicht entfernt werden. Bitte versuchen Sie es erneut.'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
isCleaning = false;
|
isCleaning = false;
|
||||||
}
|
}
|
||||||
@@ -88,13 +96,11 @@
|
|||||||
{#if isReading}
|
{#if isReading}
|
||||||
<p class="text-sm text-primary" role="status">Metadaten werden gelesen …</p>
|
<p class="text-sm text-primary" role="status">Metadaten werden gelesen …</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800" role="alert">
|
<ToolError message={error} />
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{:else if result}
|
{:else if result}
|
||||||
{#if result.status === 'encrypted'}
|
{#if result.status === 'encrypted'}
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 flex gap-3" role="alert">
|
<div class="flex gap-3 rounded-lg border border-red-200 bg-red-50 p-4" role="alert">
|
||||||
<Lock class="h-5 w-5 shrink-0 text-red-600 mt-0.5" />
|
<Lock class="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
|
||||||
<div class="text-sm text-red-800">
|
<div class="text-sm text-red-800">
|
||||||
<p class="font-medium">Dieses PDF ist verschlüsselt</p>
|
<p class="font-medium">Dieses PDF ist verschlüsselt</p>
|
||||||
<p class="mt-1">
|
<p class="mt-1">
|
||||||
@@ -105,25 +111,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded-2xl border border-border bg-card p-6 space-y-5" data-metadata-card>
|
<div class="space-y-5 rounded-2xl border border-border bg-card p-6" data-metadata-card>
|
||||||
<div class="flex items-center gap-2 text-foreground">
|
<div class="flex items-center gap-2 text-foreground">
|
||||||
<Info class="h-5 w-5" />
|
<Info class="h-5 w-5" />
|
||||||
<h2 class="text-lg font-semibold">Dokument-Informationen</h2>
|
<h2 class="text-lg font-semibold">Dokument-Informationen</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-primary truncate" title={file?.name}>{file?.name}</p>
|
<p class="truncate text-xs text-primary" title={file?.name}>{file?.name}</p>
|
||||||
|
|
||||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-[12rem_1fr]">
|
<dl class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-[12rem_1fr]">
|
||||||
{#each infoRows as row (row.label)}
|
{#each infoRows as row (row.label)}
|
||||||
<dt class="text-sm font-medium text-foreground">{row.label}</dt>
|
<dt class="text-sm font-medium text-foreground">{row.label}</dt>
|
||||||
<dd class="text-sm text-primary wrap-break-word" data-metadata-value={row.label}>
|
<dd class="text-sm wrap-break-word text-primary" data-metadata-value={row.label}>
|
||||||
{row.value || '–'}
|
{row.value || '–'}
|
||||||
</dd>
|
</dd>
|
||||||
{/each}
|
{/each}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{#if result.metadata.embeddedFileSuspected}
|
{#if result.metadata.embeddedFileSuspected}
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
<div class="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
||||||
<p class="text-sm text-amber-800">
|
<p class="text-sm text-amber-800">
|
||||||
Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen
|
Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen
|
||||||
nicht entfernt.
|
nicht entfernt.
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { ListOrdered } from '@lucide/svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import PaginationPreview from '$lib/components/PaginationPreview.svelte';
|
||||||
|
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
|
import { OVERLAY_POSITION_OPTIONS, type OverlayPosition } from '$lib/pdf-overlay';
|
||||||
|
import {
|
||||||
|
MAX_FONT_SIZE,
|
||||||
|
MAX_FREE_TEXT_LENGTH,
|
||||||
|
MAX_START_NUMBER,
|
||||||
|
MIN_FONT_SIZE,
|
||||||
|
MIN_START_NUMBER,
|
||||||
|
PAGINATION_FORMATS,
|
||||||
|
buildPaginationLabel,
|
||||||
|
normalizeFreeText,
|
||||||
|
normalizeStartNumber,
|
||||||
|
paginatePdf,
|
||||||
|
type PaginationFormat,
|
||||||
|
type PaginationTextScope
|
||||||
|
} from '$lib/pdf-pagination';
|
||||||
|
import { createPdfSession } from '$lib/pdf-session.svelte';
|
||||||
|
import {
|
||||||
|
PreviewRenderCancelledError,
|
||||||
|
loadPdfPageRenderer,
|
||||||
|
type PdfPageImage,
|
||||||
|
type PdfPageRenderer
|
||||||
|
} from '$lib/pdf-thumbnails';
|
||||||
|
import { takePendingFiles } from '$lib/pending-files';
|
||||||
|
|
||||||
|
const session = createPdfSession<PdfPageRenderer>({
|
||||||
|
load: (_file, bytes) => loadPdfPageRenderer(bytes),
|
||||||
|
dispose: (pageRenderer) => pageRenderer.destroy(),
|
||||||
|
onFileChange: () => {
|
||||||
|
currentPage = 1;
|
||||||
|
preview = null;
|
||||||
|
previewError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = $derived(session.file);
|
||||||
|
const sourceBytes = $derived(session.bytes);
|
||||||
|
const renderer = $derived(session.handle);
|
||||||
|
const pageCount = $derived(renderer?.pageCount ?? 0);
|
||||||
|
|
||||||
|
let preview = $state<PdfPageImage | null>(null);
|
||||||
|
let previewError = $state<string | null>(null);
|
||||||
|
let previewRetry = $state(0);
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let startNumberInput = $state<number | undefined>(1);
|
||||||
|
let freeText = $state('');
|
||||||
|
let textScope = $state<PaginationTextScope>('all');
|
||||||
|
let numberFormat = $state<PaginationFormat>('blatt');
|
||||||
|
let position = $state<OverlayPosition>('bottom-center');
|
||||||
|
let fontSize = $state(11);
|
||||||
|
|
||||||
|
const startNumber = $derived(normalizeStartNumber(startNumberInput ?? MIN_START_NUMBER));
|
||||||
|
const labelOptions = $derived({
|
||||||
|
startNumber,
|
||||||
|
freeText: normalizeFreeText(freeText),
|
||||||
|
textScope,
|
||||||
|
format: numberFormat
|
||||||
|
});
|
||||||
|
const previewLabel = $derived(buildPaginationLabel(currentPage - 1, labelOptions));
|
||||||
|
const exampleLabel = $derived(buildPaginationLabel(0, labelOptions));
|
||||||
|
const errorMessage = $derived(error ?? session.loadingError);
|
||||||
|
const canPaginate = $derived(sourceBytes !== null && !isProcessing && !session.isLoading);
|
||||||
|
const freeTextHint = $derived(
|
||||||
|
textScope === 'all'
|
||||||
|
? 'Der Zusatztext erscheint auf jeder Seite.'
|
||||||
|
: 'Der Zusatztext erscheint nur auf der ersten Seite.'
|
||||||
|
);
|
||||||
|
const freeTextCount = $derived(`${normalizeFreeText(freeText).length}/${MAX_FREE_TEXT_LENGTH}`);
|
||||||
|
|
||||||
|
const showPage = (pageNumber: number) => {
|
||||||
|
if (!renderer) return;
|
||||||
|
currentPage = Math.min(Math.max(1, Math.round(pageNumber || 1)), renderer.pageCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Clamps typed input on commit so the field can be cleared while editing. */
|
||||||
|
const commitStartNumber = () => {
|
||||||
|
startNumberInput = startNumber;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
const [nextFile] = files;
|
||||||
|
if (nextFile) void session.load(nextFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const paginate = async () => {
|
||||||
|
const bytes = sourceBytes;
|
||||||
|
const currentFile = file;
|
||||||
|
if (!bytes || !currentFile) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await paginatePdf(bytes, {
|
||||||
|
startNumber,
|
||||||
|
freeText,
|
||||||
|
textScope,
|
||||||
|
format: numberFormat,
|
||||||
|
position,
|
||||||
|
fontSize
|
||||||
|
});
|
||||||
|
downloadBlob(output, `${currentFile.name.replace(/\.pdf$/i, '')}_paginiert.pdf`);
|
||||||
|
} catch (processingError) {
|
||||||
|
console.error('Pagination failed:', processingError);
|
||||||
|
error = pdfProcessingErrorMessage(
|
||||||
|
processingError,
|
||||||
|
'Das PDF konnte nicht paginiert werden. Prüfen Sie die Datei.'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeRenderer = renderer;
|
||||||
|
const pageNumber = currentPage;
|
||||||
|
void previewRetry;
|
||||||
|
if (!activeRenderer) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void activeRenderer
|
||||||
|
.render(pageNumber)
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
preview = result;
|
||||||
|
previewError = null;
|
||||||
|
})
|
||||||
|
.catch((renderError) => {
|
||||||
|
if (cancelled || renderError instanceof PreviewRenderCancelledError) return;
|
||||||
|
console.error('Failed to render pagination preview:', renderError);
|
||||||
|
preview = null;
|
||||||
|
previewError = 'Die Vorschau dieser Seite konnte nicht erzeugt werden.';
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const [pending] = takePendingFiles('pagination');
|
||||||
|
if (pending) void session.load(pending);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
session.clear();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PDF paginieren — beA-Edit</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-5xl flex-col gap-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-secondary">
|
||||||
|
<ListOrdered class="size-5" aria-hidden="true" />
|
||||||
|
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Seitenzählung</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Paginieren</h1>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm text-primary">
|
||||||
|
Versehen Sie alle Seiten mit fortlaufenden Blattnummern — auf Wunsch kombiniert mit einem
|
||||||
|
Aktenzeichen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
label={file ? 'Andere PDF wählen' : 'PDF zum Paginieren hinzufügen'}
|
||||||
|
sublabel={session.isLoading
|
||||||
|
? 'PDF wird geladen …'
|
||||||
|
: file
|
||||||
|
? `${file.name}${pageCount > 0 ? ` · ${pageCount} Seite${pageCount === 1 ? '' : 'n'}` : ''}`
|
||||||
|
: 'PDF hier ablegen oder auswählen'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if file}
|
||||||
|
<RasterizationWarning {file} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if file && renderer}
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="pagination-options-title"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 id="pagination-options-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Nummerierung einstellen
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Die Nummern laufen über das gesamte Dokument und werden für schmale Seiten automatisch
|
||||||
|
verkleinert (bis {MIN_FONT_SIZE} pt).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-start">
|
||||||
|
Startnummer
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pagination-start"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
step="1"
|
||||||
|
min={MIN_START_NUMBER}
|
||||||
|
max={MAX_START_NUMBER}
|
||||||
|
bind:value={startNumberInput}
|
||||||
|
onchange={commitStartNumber}
|
||||||
|
onblur={commitStartNumber}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Seite 1 erhält diese Nummer, danach zählt das Dokument fortlaufend weiter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-text">
|
||||||
|
Zusatztext (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pagination-text"
|
||||||
|
type="text"
|
||||||
|
bind:value={freeText}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p id="pagination-text-help" class="text-xs text-muted-foreground">
|
||||||
|
Höchstens {MAX_FREE_TEXT_LENGTH} Zeichen ({freeTextCount}), zum Beispiel ein
|
||||||
|
Aktenzeichen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="text-sm font-medium text-foreground">Zusatztext anzeigen</legend>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="pagination-text-scope"
|
||||||
|
value="all"
|
||||||
|
checked={textScope === 'all'}
|
||||||
|
onchange={() => (textScope = 'all')}
|
||||||
|
/>
|
||||||
|
Auf jeder Seite
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="pagination-text-scope"
|
||||||
|
value="first"
|
||||||
|
checked={textScope === 'first'}
|
||||||
|
onchange={() => (textScope = 'first')}
|
||||||
|
/>
|
||||||
|
Nur auf der ersten Seite
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground">{freeTextHint}</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-format">
|
||||||
|
Zahlenformat
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="pagination-format"
|
||||||
|
bind:value={numberFormat}
|
||||||
|
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{#each PAGINATION_FORMATS as format (format.value)}
|
||||||
|
<option value={format.value}>{format.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-position">
|
||||||
|
Position
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="pagination-position"
|
||||||
|
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)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-font-size">
|
||||||
|
Schriftgröße: {fontSize} pt
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pagination-font-size"
|
||||||
|
type="range"
|
||||||
|
min={MIN_FONT_SIZE}
|
||||||
|
max={MAX_FONT_SIZE}
|
||||||
|
bind:value={fontSize}
|
||||||
|
aria-valuetext="{fontSize} Punkt"
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground sm:col-span-2">
|
||||||
|
Wunschgröße; reicht der Platz nicht, wird der Text bis {MIN_FONT_SIZE} pt verkleinert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-foreground"
|
||||||
|
data-testid="pagination-example"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Beispiel: {exampleLabel}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PaginationPreview
|
||||||
|
{preview}
|
||||||
|
error={previewError}
|
||||||
|
label={previewLabel}
|
||||||
|
{fontSize}
|
||||||
|
{position}
|
||||||
|
{currentPage}
|
||||||
|
{pageCount}
|
||||||
|
onRetry={() => (previewRetry += 1)}
|
||||||
|
onShowPage={showPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end gap-3">
|
||||||
|
<ToolError message={errorMessage} class="w-full" />
|
||||||
|
{#if renderer}
|
||||||
|
<Button size="large" onclick={paginate} disabled={!canPaginate}>
|
||||||
|
{isProcessing ? 'Wird paginiert …' : 'Paginieren & herunterladen'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,52 +2,36 @@
|
|||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { degrees, PDFDocument } from 'pdf-lib';
|
import { degrees, PDFDocument } from 'pdf-lib';
|
||||||
import { RotateCcw, RotateCw } from '@lucide/svelte';
|
import { RotateCcw, RotateCw } from '@lucide/svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
|
||||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import SinglePdfTool from '$lib/components/SinglePdfTool.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { downloadBlob } from '$lib/download';
|
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';
|
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<number, number>());
|
let rotations = $state(new Map<number, number>());
|
||||||
let isProcessing = $state(false);
|
let isProcessing = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let loadingError = $state<string | null>(null);
|
|
||||||
|
|
||||||
|
const session = createPdfSession<PdfDocumentHandle>({
|
||||||
|
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 rotatedCount = $derived(rotations.size);
|
||||||
const hasRotations = $derived(rotatedCount > 0);
|
const hasRotations = $derived(rotatedCount > 0);
|
||||||
|
|
||||||
const isEncryptedPdfError = (loadError: unknown) => {
|
const handleFileSelected = (files: File[]) => {
|
||||||
const message =
|
const [file] = files;
|
||||||
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
|
if (file) void session.load(file);
|
||||||
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 rotatePage = (pageNumber: number, delta: number) => {
|
const rotatePage = (pageNumber: number, delta: number) => {
|
||||||
@@ -63,8 +47,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rotateAll = (delta: number) => {
|
const rotateAll = (delta: number) => {
|
||||||
if (!pdfHandle) return;
|
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||||||
for (let pageNumber = 1; pageNumber <= pdfHandle.pageCount; pageNumber += 1) {
|
|
||||||
rotatePage(pageNumber, delta);
|
rotatePage(pageNumber, delta);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -88,13 +71,14 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rotateAndDownload = async () => {
|
const rotateAndDownload = async () => {
|
||||||
if (!pdfFile || !pdfHandle || !hasRotations) return;
|
const bytes = session.bytes;
|
||||||
|
const currentFile = session.file;
|
||||||
|
if (!bytes || !currentFile || !hasRotations) return;
|
||||||
|
|
||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
|
||||||
const pdfDoc = await PDFDocument.load(bytes);
|
const pdfDoc = await PDFDocument.load(bytes);
|
||||||
for (const [index, page] of pdfDoc.getPages().entries()) {
|
for (const [index, page] of pdfDoc.getPages().entries()) {
|
||||||
const delta = rotations.get(index + 1);
|
const delta = rotations.get(index + 1);
|
||||||
@@ -104,116 +88,91 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resultBytes = await pdfDoc.save();
|
const resultBytes = await pdfDoc.save();
|
||||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
const baseName = currentFile.name.replace(/\.pdf$/i, '');
|
||||||
downloadBlob(resultBytes, `${baseName}_gedreht.pdf`);
|
downloadBlob(resultBytes, `${baseName}_gedreht.pdf`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to rotate pages:', err);
|
console.error('Failed to rotate pages:', err);
|
||||||
error = isEncryptedPdfError(err)
|
error = pdfProcessingErrorMessage(err, 'Fehler beim Drehen der Seiten.');
|
||||||
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
|
|
||||||
: 'Fehler beim Drehen der Seiten.';
|
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
pdfHandle?.destroy();
|
session.clear();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<SinglePdfTool
|
||||||
<div>
|
title="Seiten drehen"
|
||||||
<h1 class="text-2xl font-bold text-primary-900">Seiten drehen</h1>
|
description="Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust."
|
||||||
<p class="mt-2 text-sm text-primary-700">
|
file={pdfFile}
|
||||||
Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust.
|
{pageCount}
|
||||||
</p>
|
loadingError={session.loadingError}
|
||||||
</div>
|
{error}
|
||||||
|
onFilesSelected={handleFileSelected}
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(90)}>
|
||||||
|
Alle 90° rechts
|
||||||
|
</Button>
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(270)}>
|
||||||
|
Alle 90° links
|
||||||
|
</Button>
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(180)}>
|
||||||
|
Alle 180°
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={resetRotations}
|
||||||
|
disabled={!hasRotations}
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#if !pdfHandle}
|
<PdfPageGrid
|
||||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
thumbnails={pdfHandle?.thumbnails ?? []}
|
||||||
{#if loadingError}
|
thumbnailWidth={160}
|
||||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
cardClass={previewClass}
|
||||||
{loadingError}
|
>
|
||||||
</div>
|
{#snippet pageActions(pageNumber)}
|
||||||
{/if}
|
{#if (rotations.get(pageNumber) ?? 0) !== 0}
|
||||||
{:else}
|
<span
|
||||||
<div class="space-y-4">
|
class="absolute top-1 right-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold text-white"
|
||||||
<FileDropzone
|
>
|
||||||
onFilesSelected={handleFileSelected}
|
{rotations.get(pageNumber)}°
|
||||||
label="Anderes PDF ablegen"
|
</span>
|
||||||
sublabel={pdfFile?.name ?? ''}
|
|
||||||
class="py-4"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<h2 class="text-lg font-semibold text-primary-900">
|
|
||||||
{pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
|
||||||
</h2>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(90)}>
|
|
||||||
Alle 90° rechts
|
|
||||||
</Button>
|
|
||||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(270)}>
|
|
||||||
Alle 90° links
|
|
||||||
</Button>
|
|
||||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(180)}>
|
|
||||||
Alle 180°
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
class="min-w-0 px-3"
|
|
||||||
onclick={resetRotations}
|
|
||||||
disabled={!hasRotations}
|
|
||||||
>
|
|
||||||
Zurücksetzen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PdfPageGrid thumbnails={pdfHandle.thumbnails} thumbnailWidth={160} cardClass={previewClass}>
|
|
||||||
{#snippet pageActions(pageNumber)}
|
|
||||||
{#if (rotations.get(pageNumber) ?? 0) !== 0}
|
|
||||||
<span
|
|
||||||
class="absolute top-1 right-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold text-white"
|
|
||||||
>
|
|
||||||
{rotations.get(pageNumber)}°
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
|
||||||
aria-label="Seite {pageNumber} um 90° nach links drehen"
|
|
||||||
onclick={() => rotatePage(pageNumber, 270)}
|
|
||||||
>
|
|
||||||
<RotateCcw class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
|
||||||
aria-label="Seite {pageNumber} um 90° nach rechts drehen"
|
|
||||||
onclick={() => rotatePage(pageNumber, 90)}
|
|
||||||
>
|
|
||||||
<RotateCw class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</PdfPageGrid>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="flex gap-1">
|
||||||
<div class="flex items-center justify-between">
|
<button
|
||||||
<p class="text-sm text-primary-700">
|
type="button"
|
||||||
{rotatedCount} von {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
class="border-primary-200 hover:bg-primary-50 focus-visible:ring-primary-400 flex h-7 w-7 items-center justify-center rounded-lg border bg-white text-primary transition focus:outline-none focus-visible:ring-2"
|
||||||
gedreht
|
aria-label="Seite {pageNumber} um 90° nach links drehen"
|
||||||
</p>
|
onclick={() => rotatePage(pageNumber, 270)}
|
||||||
<Button onclick={rotateAndDownload} disabled={isProcessing || !hasRotations}>
|
>
|
||||||
{isProcessing ? 'Wird verarbeitet...' : 'Drehen & herunterladen'}
|
<RotateCcw class="h-4 w-4" />
|
||||||
</Button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-primary-200 hover:bg-primary-50 focus-visible:ring-primary-400 flex h-7 w-7 items-center justify-center rounded-lg border bg-white text-primary transition focus:outline-none focus-visible:ring-2"
|
||||||
|
aria-label="Seite {pageNumber} um 90° nach rechts drehen"
|
||||||
|
onclick={() => rotatePage(pageNumber, 90)}
|
||||||
|
>
|
||||||
|
<RotateCw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/snippet}
|
||||||
{/if}
|
</PdfPageGrid>
|
||||||
</div>
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<p class="text-primary-700 text-sm">
|
||||||
|
{rotatedCount} von {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||||||
|
gedreht
|
||||||
|
</p>
|
||||||
|
<Button onclick={rotateAndDownload} disabled={isProcessing || !hasRotations}>
|
||||||
|
{isProcessing ? 'Wird verarbeitet...' : 'Drehen & herunterladen'}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</SinglePdfTool>
|
||||||
@@ -1,38 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
|
||||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import SinglePdfTool from '$lib/components/SinglePdfTool.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
import { copyPagesToNewPdf } from '$lib/pdf-processing';
|
import { copyPagesToNewPdf } from '$lib/pdf-processing';
|
||||||
|
import { createPdfSession } from '$lib/pdf-session.svelte';
|
||||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
let pdfFile: File | null = $state(null);
|
|
||||||
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
|
||||||
let selectedPages = $state(new Set<number>());
|
let selectedPages = $state(new Set<number>());
|
||||||
let isProcessing = $state(false);
|
let isProcessing = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let loadingError = $state<string | null>(null);
|
|
||||||
|
|
||||||
const handleFileSelected = async (files: File[]) => {
|
const session = createPdfSession<PdfDocumentHandle>({
|
||||||
const file = files[0];
|
load: (_file, bytes) => loadPdfDocument(bytes),
|
||||||
if (!file) return;
|
dispose: (handle) => handle.destroy(),
|
||||||
|
onFileChange: () => {
|
||||||
pdfHandle?.destroy();
|
selectedPages = new Set();
|
||||||
pdfHandle = null;
|
error = null;
|
||||||
pdfFile = file;
|
|
||||||
selectedPages = new Set();
|
|
||||||
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 = 'PDF konnte nicht geladen werden.';
|
|
||||||
pdfHandle = null;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfFile = $derived(session.file);
|
||||||
|
const pdfHandle = $derived(session.handle);
|
||||||
|
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
|
||||||
|
|
||||||
|
const handleFileSelected = (files: File[]) => {
|
||||||
|
const [file] = files;
|
||||||
|
if (file) void session.load(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePage = (pageNumber: number) => {
|
const togglePage = (pageNumber: number) => {
|
||||||
@@ -46,9 +42,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
if (!pdfHandle) return;
|
|
||||||
const all = new Set<number>();
|
const all = new Set<number>();
|
||||||
for (let i = 1; i <= pdfHandle.pageCount; i++) all.add(i);
|
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) all.add(pageNumber);
|
||||||
selectedPages = all;
|
selectedPages = all;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,18 +52,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removePages = async () => {
|
const removePages = async () => {
|
||||||
if (!pdfFile || !pdfHandle || selectedPages.size === 0) return;
|
const bytes = session.bytes;
|
||||||
|
const currentFile = session.file;
|
||||||
|
if (!bytes || !currentFile || selectedPages.size === 0) return;
|
||||||
|
|
||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
|
||||||
|
|
||||||
const pagesToKeep: number[] = [];
|
const pagesToKeep: number[] = [];
|
||||||
for (let i = 0; i < pdfHandle.pageCount; i++) {
|
for (let index = 0; index < pageCount; index += 1) {
|
||||||
if (!selectedPages.has(i + 1)) {
|
if (!selectedPages.has(index + 1)) {
|
||||||
pagesToKeep.push(i);
|
pagesToKeep.push(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,79 +74,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resultBytes = await copyPagesToNewPdf(bytes, pagesToKeep);
|
const resultBytes = await copyPagesToNewPdf(bytes, pagesToKeep);
|
||||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
const baseName = currentFile.name.replace(/\.pdf$/i, '');
|
||||||
downloadBlob(resultBytes, `${baseName}_ohne_seiten.pdf`);
|
downloadBlob(resultBytes, `${baseName}_ohne_seiten.pdf`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to remove pages:', err);
|
console.error('Failed to remove pages:', err);
|
||||||
error = 'Fehler beim Entfernen der Seiten.';
|
error = pdfProcessingErrorMessage(err, 'Fehler beim Entfernen der Seiten.');
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
pdfHandle?.destroy();
|
session.clear();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<SinglePdfTool
|
||||||
<div>
|
title="Seiten entfernen"
|
||||||
<h1 class="text-2xl font-bold text-primary-900">Seiten entfernen</h1>
|
description="Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten."
|
||||||
<p class="mt-2 text-sm text-primary-700">
|
file={pdfFile}
|
||||||
Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten.
|
{pageCount}
|
||||||
|
loadingError={session.loadingError}
|
||||||
|
{error}
|
||||||
|
onFilesSelected={handleFileSelected}
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectAll}>Alle auswählen</Button>
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectNone}>Auswahl aufheben</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PdfPageGrid thumbnails={pdfHandle?.thumbnails ?? []} {selectedPages} onPageClick={togglePage} />
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<p class="text-primary-700 text-sm">
|
||||||
|
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<Button
|
||||||
|
onclick={removePages}
|
||||||
{#if !pdfHandle}
|
disabled={isProcessing || selectedPages.size === 0 || selectedPages.size >= pageCount}
|
||||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
>
|
||||||
{#if loadingError}
|
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
|
||||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
</Button>
|
||||||
{loadingError}
|
{/snippet}
|
||||||
</div>
|
</SinglePdfTool>
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<FileDropzone
|
|
||||||
onFilesSelected={handleFileSelected}
|
|
||||||
label="Anderes PDF ablegen"
|
|
||||||
sublabel={pdfFile?.name ?? ''}
|
|
||||||
class="py-4"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<h2 class="text-lg font-semibold text-primary-900">
|
|
||||||
{pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
|
||||||
</h2>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectAll}>
|
|
||||||
Alle auswählen
|
|
||||||
</Button>
|
|
||||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectNone}>
|
|
||||||
Auswahl aufheben
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-sm text-primary-700">
|
|
||||||
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onclick={removePages}
|
|
||||||
disabled={isProcessing ||
|
|
||||||
selectedPages.size === 0 ||
|
|
||||||
selectedPages.size >= pdfHandle.pageCount}
|
|
||||||
>
|
|
||||||
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
+287
-352
@@ -1,19 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
|
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
|
||||||
import { degrees, PDFDocument, StandardFonts, type PDFFont } from 'pdf-lib';
|
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
import { getPositionCoords, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
|
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
|
import { createLatestToken } from '$lib/latest-task';
|
||||||
|
import {
|
||||||
|
OVERLAY_POSITION_OPTIONS,
|
||||||
|
getPreviewPositionStyle,
|
||||||
|
parseHexColor,
|
||||||
|
type OverlayPosition
|
||||||
|
} from '$lib/pdf-overlay';
|
||||||
|
import {
|
||||||
|
MIN_TEXT_OVERLAY_SIZE,
|
||||||
|
TEXT_OVERLAY_MARGIN,
|
||||||
|
TEXT_OVERLAY_PADDING,
|
||||||
|
drawImageOverlay,
|
||||||
|
drawTextOverlay
|
||||||
|
} from '$lib/pdf-text-overlay';
|
||||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
|
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
|
||||||
const PADDING = 8;
|
|
||||||
const PAGE_MARGIN = 20;
|
|
||||||
const MIN_EXPORT_FONT_SIZE = 4;
|
|
||||||
const MIN_EXPORT_IMAGE_SIZE = 20;
|
|
||||||
|
|
||||||
type StampImage = {
|
type StampImage = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,7 +37,7 @@
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let pdfFile: File | null = $state(null);
|
let items = $state<BatchItem[]>([]);
|
||||||
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||||
let stampInput = $state('BEGLAUBIGT');
|
let stampInput = $state('BEGLAUBIGT');
|
||||||
let withDate = $state(false);
|
let withDate = $state(false);
|
||||||
@@ -40,9 +53,9 @@
|
|||||||
let selectedPages = $state(new Set<number>());
|
let selectedPages = $state(new Set<number>());
|
||||||
let currentPage = $state(1);
|
let currentPage = $state(1);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isProcessing = $state(false);
|
let isRunning = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let handleGeneration = 0;
|
const loadToken = createLatestToken();
|
||||||
|
|
||||||
const formatGermanDate = (date: Date) =>
|
const formatGermanDate = (date: Date) =>
|
||||||
[date.getDate(), date.getMonth() + 1, date.getFullYear()]
|
[date.getDate(), date.getMonth() + 1, date.getFullYear()]
|
||||||
@@ -58,55 +71,55 @@
|
|||||||
const selectedPreset = $derived(
|
const selectedPreset = $derived(
|
||||||
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
|
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
|
||||||
);
|
);
|
||||||
const canProcess = $derived(
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
pdfFile !== null &&
|
const stampReady = $derived(stampImage !== null || previewText.length > 0);
|
||||||
pdfHandle !== null &&
|
const pageSelectionMissing = $derived(
|
||||||
(stampImage !== null || previewText.length > 0) &&
|
items.length === 1 && pdfHandle !== null && !applyToAll && selectedPages.size === 0
|
||||||
!isLoading &&
|
);
|
||||||
!isLoadingImage &&
|
const canRun = $derived(
|
||||||
!isProcessing &&
|
!isRunning && pendingCount > 0 && stampReady && !isLoadingImage && !pageSelectionMissing
|
||||||
(applyToAll || selectedPages.size > 0)
|
|
||||||
);
|
);
|
||||||
const previewFontSize = $derived(
|
const previewFontSize = $derived(
|
||||||
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
|
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEncryptedPdfError = (loadError: unknown) => {
|
const handleFilesSelected = async (files: File[]) => {
|
||||||
const message =
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
|
const additions = files
|
||||||
return /password|encrypted|encryption|verschl/i.test(message);
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
};
|
.map((file) => createBatchItem(file));
|
||||||
|
if (additions.length === 0) return;
|
||||||
|
|
||||||
const loadErrorMessage = (loadError: unknown) =>
|
items = [...items, ...additions];
|
||||||
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;
|
|
||||||
|
|
||||||
const generation = ++handleGeneration;
|
|
||||||
pdfHandle?.destroy();
|
|
||||||
pdfHandle = null;
|
|
||||||
pdfFile = file;
|
|
||||||
selectedPages = new Set();
|
selectedPages = new Set();
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
error = null;
|
error = null;
|
||||||
isLoading = true;
|
|
||||||
|
|
||||||
|
// Thumbnails and page selection are only available for a single file so
|
||||||
|
// memory stays flat during multi-file runs.
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
const ticket = loadToken.begin();
|
||||||
|
|
||||||
|
if (items.length !== 1) {
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = items;
|
||||||
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const handle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
|
const handle = await loadPdfDocument(new Uint8Array(await item.file.arrayBuffer()));
|
||||||
if (generation !== handleGeneration) {
|
if (!loadToken.isCurrent(ticket) || items.length !== 1) {
|
||||||
handle.destroy();
|
handle.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pdfHandle = handle;
|
pdfHandle = handle;
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
console.error('Failed to load PDF for stamping:', loadError);
|
console.error('Failed to load PDF for stamping:', loadError);
|
||||||
if (generation === handleGeneration) error = loadErrorMessage(loadError);
|
if (loadToken.isCurrent(ticket)) error = pdfLoadErrorMessage(loadError);
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === handleGeneration) isLoading = false;
|
if (loadToken.isCurrent(ticket)) isLoading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,224 +202,132 @@
|
|||||||
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
|
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPreviewPositionStyle = (previewPosition: OverlayPosition) => {
|
const processFile = async (file: File) => {
|
||||||
switch (previewPosition) {
|
// Options are read per file so a change during a run only affects the
|
||||||
case 'center':
|
// still pending files. With multiple files the stamp is applied to all
|
||||||
return 'left: 50%; top: 50%; transform: translate(-50%, -50%);';
|
// pages of every file.
|
||||||
case 'top-left':
|
const stampText = buildStampText(stampInput, withDate, new Date());
|
||||||
return 'left: 5%; top: 5%;';
|
const numericFontSize = Number(fontSize);
|
||||||
case 'top-center':
|
const numericImageSize = Number(imageSize);
|
||||||
return 'left: 50%; top: 5%; transform: translateX(-50%);';
|
const withFrame = withBorder;
|
||||||
case 'top-right':
|
const angle = Number(rotation);
|
||||||
return 'right: 5%; top: 5%;';
|
const stampPosition = position;
|
||||||
case 'bottom-left':
|
const color = parseHexColor(stampColor);
|
||||||
return 'bottom: 5%; left: 5%;';
|
const currentStampImage = stampImage;
|
||||||
case 'bottom-center':
|
|
||||||
return 'bottom: 5%; left: 50%; transform: translateX(-50%);';
|
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||||
case 'bottom-right':
|
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
return 'bottom: 5%; right: 5%;';
|
const embeddedImage = currentStampImage
|
||||||
|
? currentStampImage.mimeType === 'image/png'
|
||||||
|
? await pdfDoc.embedPng(currentStampImage.bytes)
|
||||||
|
: await pdfDoc.embedJpg(currentStampImage.bytes)
|
||||||
|
: null;
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const useAllPages = items.length > 1 || applyToAll;
|
||||||
|
const pageIndexes = useAllPages
|
||||||
|
? pages.map((_, index) => index)
|
||||||
|
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
||||||
|
|
||||||
|
for (const pageIndex of pageIndexes) {
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
if (!page) continue;
|
||||||
|
|
||||||
|
if (embeddedImage) {
|
||||||
|
drawImageOverlay(page, {
|
||||||
|
image: embeddedImage,
|
||||||
|
size: numericImageSize,
|
||||||
|
color,
|
||||||
|
position: stampPosition,
|
||||||
|
margin: TEXT_OVERLAY_MARGIN,
|
||||||
|
padding: TEXT_OVERLAY_PADDING,
|
||||||
|
framed: withFrame,
|
||||||
|
rotation: angle
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTextOverlay(page, {
|
||||||
|
font,
|
||||||
|
text: stampText,
|
||||||
|
size: numericFontSize,
|
||||||
|
color,
|
||||||
|
position: stampPosition,
|
||||||
|
margin: TEXT_OVERLAY_MARGIN,
|
||||||
|
padding: TEXT_OVERLAY_PADDING,
|
||||||
|
framed: true,
|
||||||
|
rotation: angle,
|
||||||
|
minSize: MIN_TEXT_OVERLAY_SIZE,
|
||||||
|
tooLongMessage:
|
||||||
|
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bytes = await pdfDoc.save();
|
||||||
|
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_stempel.pdf` };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRotatedStampPlacement = (
|
const stampErrorMessage = (processingError: unknown) =>
|
||||||
pageWidth: number,
|
pdfProcessingErrorMessage(
|
||||||
pageHeight: number,
|
processingError,
|
||||||
boxWidth: number,
|
'Diese PDF konnte nicht gestempelt werden. Prüfen Sie die Datei.'
|
||||||
boxHeight: number,
|
|
||||||
stampPosition: OverlayPosition,
|
|
||||||
angle: number
|
|
||||||
) => {
|
|
||||||
const radians = (angle * Math.PI) / 180;
|
|
||||||
const cosine = Math.cos(radians);
|
|
||||||
const sine = Math.sin(radians);
|
|
||||||
const rotatedCorners = [
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: boxWidth * cosine, y: boxWidth * sine },
|
|
||||||
{ x: -boxHeight * sine, y: boxHeight * cosine },
|
|
||||||
{
|
|
||||||
x: boxWidth * cosine - boxHeight * sine,
|
|
||||||
y: boxWidth * sine + boxHeight * cosine
|
|
||||||
}
|
|
||||||
];
|
|
||||||
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
|
|
||||||
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
|
|
||||||
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
|
|
||||||
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
|
|
||||||
const boundingCoordinates = getPositionCoords(
|
|
||||||
pageWidth,
|
|
||||||
pageHeight,
|
|
||||||
maxX - minX,
|
|
||||||
maxY - minY,
|
|
||||||
stampPosition,
|
|
||||||
PAGE_MARGIN
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
x: boundingCoordinates.x - minX,
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
y: boundingCoordinates.y - minY,
|
|
||||||
cosine,
|
|
||||||
sine
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fitStampToPage = (
|
const startBatch = async () => {
|
||||||
font: PDFFont,
|
if (isRunning || !canRun) return;
|
||||||
text: string,
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
requestedSize: number,
|
if (queue.length === 0) return;
|
||||||
pageWidth: number,
|
|
||||||
pageHeight: number
|
|
||||||
) => {
|
|
||||||
const availableWidth = pageWidth - PAGE_MARGIN * 2;
|
|
||||||
const availableHeight = pageHeight - PAGE_MARGIN * 2;
|
|
||||||
let size = requestedSize;
|
|
||||||
|
|
||||||
while (size > MIN_EXPORT_FONT_SIZE) {
|
|
||||||
const width = font.widthOfTextAtSize(text, size) + PADDING * 2;
|
|
||||||
const height = font.heightAtSize(size) + PADDING * 2;
|
|
||||||
if (width <= availableWidth && height <= availableHeight) break;
|
|
||||||
size = Math.max(MIN_EXPORT_FONT_SIZE, size - 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textWidth = font.widthOfTextAtSize(text, size);
|
|
||||||
const textHeight = font.heightAtSize(size);
|
|
||||||
if (textWidth + PADDING * 2 > availableWidth || textHeight + PADDING * 2 > availableHeight) {
|
|
||||||
throw new Error(
|
|
||||||
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
size,
|
|
||||||
textHeight,
|
|
||||||
boxWidth: textWidth + PADDING * 2,
|
|
||||||
boxHeight: textHeight + PADDING * 2
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getImageStampBox = (
|
|
||||||
image: { width: number; height: number },
|
|
||||||
pageWidth: number,
|
|
||||||
pageHeight: number
|
|
||||||
) => {
|
|
||||||
const padding = withBorder ? PADDING : 0;
|
|
||||||
const aspectRatio = image.width / image.height;
|
|
||||||
let drawHeight = Number(imageSize);
|
|
||||||
let drawWidth = drawHeight * aspectRatio;
|
|
||||||
|
|
||||||
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
|
|
||||||
const availableHeight = pageHeight - PAGE_MARGIN * 2 - padding * 2;
|
|
||||||
if (drawWidth > availableWidth || drawHeight > availableHeight) {
|
|
||||||
const scale = Math.min(availableWidth / drawWidth, availableHeight / drawHeight);
|
|
||||||
drawWidth *= scale;
|
|
||||||
drawHeight *= scale;
|
|
||||||
}
|
|
||||||
if (drawHeight < MIN_EXPORT_IMAGE_SIZE) {
|
|
||||||
drawHeight = MIN_EXPORT_IMAGE_SIZE;
|
|
||||||
drawWidth = drawHeight * aspectRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
drawWidth,
|
|
||||||
drawHeight,
|
|
||||||
boxWidth: drawWidth + padding * 2,
|
|
||||||
boxHeight: drawHeight + padding * 2,
|
|
||||||
padding
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyStamp = async () => {
|
|
||||||
if (!pdfFile || !pdfHandle || !canProcess) return;
|
|
||||||
|
|
||||||
isProcessing = true;
|
|
||||||
error = null;
|
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
try {
|
try {
|
||||||
const stampText = buildStampText(stampInput, withDate, new Date());
|
await runBatch(queue, processFile, updateItem, {
|
||||||
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
|
errorMessage: stampErrorMessage,
|
||||||
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
const embeddedImage = stampImage
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
? stampImage.mimeType === 'image/png'
|
});
|
||||||
? await pdfDoc.embedPng(stampImage.bytes)
|
|
||||||
: await pdfDoc.embedJpg(stampImage.bytes)
|
|
||||||
: null;
|
|
||||||
const pages = pdfDoc.getPages();
|
|
||||||
const pageIndexes = applyToAll
|
|
||||||
? pages.map((_, index) => index)
|
|
||||||
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
|
||||||
const color = parseHexColor(stampColor);
|
|
||||||
|
|
||||||
for (const pageIndex of pageIndexes) {
|
|
||||||
const page = pages[pageIndex];
|
|
||||||
if (!page) continue;
|
|
||||||
|
|
||||||
const { width, height } = page.getSize();
|
|
||||||
const angle = Number(rotation);
|
|
||||||
const box = embeddedImage
|
|
||||||
? getImageStampBox(embeddedImage, width, height)
|
|
||||||
: fitStampToPage(font, stampText, Number(fontSize), width, height);
|
|
||||||
const placement = getRotatedStampPlacement(
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
box.boxWidth,
|
|
||||||
box.boxHeight,
|
|
||||||
position,
|
|
||||||
angle
|
|
||||||
);
|
|
||||||
|
|
||||||
if (embeddedImage ? withBorder : true) {
|
|
||||||
page.drawRectangle({
|
|
||||||
x: placement.x,
|
|
||||||
y: placement.y,
|
|
||||||
width: box.boxWidth,
|
|
||||||
height: box.boxHeight,
|
|
||||||
borderColor: color,
|
|
||||||
borderWidth: 2,
|
|
||||||
rotate: degrees(angle)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (embeddedImage) {
|
|
||||||
const imageBox = box as { drawWidth: number; drawHeight: number; padding: number };
|
|
||||||
const offsetX = imageBox.padding;
|
|
||||||
const offsetY = imageBox.padding;
|
|
||||||
page.drawImage(embeddedImage, {
|
|
||||||
x: placement.x + offsetX * placement.cosine - offsetY * placement.sine,
|
|
||||||
y: placement.y + offsetX * placement.sine + offsetY * placement.cosine,
|
|
||||||
width: imageBox.drawWidth,
|
|
||||||
height: imageBox.drawHeight,
|
|
||||||
rotate: degrees(angle)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const textOffsetX = PADDING;
|
|
||||||
const textOffsetY = PADDING + (box as { textHeight: number }).textHeight * 0.2;
|
|
||||||
page.drawText(stampText, {
|
|
||||||
x: placement.x + textOffsetX * placement.cosine - textOffsetY * placement.sine,
|
|
||||||
y: placement.y + textOffsetX * placement.sine + textOffsetY * placement.cosine,
|
|
||||||
size: (box as { size: number }).size,
|
|
||||||
font,
|
|
||||||
color,
|
|
||||||
rotate: degrees(angle)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultBytes = await pdfDoc.save();
|
|
||||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
|
||||||
downloadBlob(resultBytes, `${baseName}_stempel.pdf`);
|
|
||||||
} catch (processingError) {
|
|
||||||
console.error('Failed to stamp PDF:', processingError);
|
|
||||||
error = isEncryptedPdfError(processingError)
|
|
||||||
? loadErrorMessage(processingError)
|
|
||||||
: processingError instanceof Error && processingError.message.startsWith('Der Stempeltext')
|
|
||||||
? processingError.message
|
|
||||||
: 'Der Stempel konnte nicht angewendet werden. Prüfen Sie die PDF-Datei.';
|
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isRunning = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage: stampErrorMessage
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
const removingPreviewedFile = items.length === 1 && items[0]?.id === id;
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
if (removingPreviewedFile) {
|
||||||
|
loadToken.invalidate();
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
selectedPages = new Set();
|
||||||
|
currentPage = 1;
|
||||||
|
error = null;
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
handleGeneration += 1;
|
loadToken.invalidate();
|
||||||
pdfHandle?.destroy();
|
pdfHandle?.destroy();
|
||||||
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
||||||
});
|
});
|
||||||
@@ -429,15 +350,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
onFilesSelected={handleFileSelected}
|
onFilesSelected={handleFilesSelected}
|
||||||
label={pdfHandle ? 'Anderes PDF ablegen' : 'PDF zum Stempeln hinzufügen'}
|
multiple
|
||||||
|
label={items.length === 0 ? 'PDFs zum Stempeln hinzufügen' : 'Weitere PDFs hinzufügen'}
|
||||||
sublabel={isLoading
|
sublabel={isLoading
|
||||||
? 'Vorschau wird geladen …'
|
? 'Vorschau wird geladen …'
|
||||||
: (pdfFile?.name ?? 'PDF hier ablegen oder auswählen')}
|
: items.length === 1
|
||||||
|
? items[0].file.name
|
||||||
|
: items.length === 0
|
||||||
|
? 'PDFs hier ablegen oder auswählen'
|
||||||
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if pdfHandle}
|
{#if items.length > 0}
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
<div class="grid gap-6 {pdfHandle ? 'lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]' : ''}">
|
||||||
<section
|
<section
|
||||||
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
||||||
aria-labelledby="stamp-options-title"
|
aria-labelledby="stamp-options-title"
|
||||||
@@ -447,7 +373,8 @@
|
|||||||
Stempel einstellen
|
Stempel einstellen
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
Lange Texte werden für kleine Seiten automatisch verkleinert.
|
Lange Texte werden für kleine Seiten automatisch verkleinert. Die Einstellungen gelten
|
||||||
|
für alle Dateien der Liste.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -570,13 +497,9 @@
|
|||||||
bind:value={position}
|
bind:value={position}
|
||||||
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="top-left">Oben links</option>
|
{#each OVERLAY_POSITION_OPTIONS as option (option.value)}
|
||||||
<option value="top-center">Oben mittig</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
<option value="top-right">Oben rechts</option>
|
{/each}
|
||||||
<option value="center">Zentriert</option>
|
|
||||||
<option value="bottom-left">Unten links</option>
|
|
||||||
<option value="bottom-center">Unten mittig</option>
|
|
||||||
<option value="bottom-right">Unten rechts</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -590,92 +513,108 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
|
{#if items.length > 1}
|
||||||
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
|
<p class="text-sm text-primary" role="note">
|
||||||
Auf alle Seiten anwenden
|
Der Stempel wird auf alle Seiten aller Dateien angewendet.
|
||||||
</label>
|
</p>
|
||||||
|
{:else}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
|
||||||
|
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
|
||||||
|
Auf alle Seiten anwenden
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isRunning}
|
||||||
|
<p class="text-xs text-primary" role="note">
|
||||||
|
Änderungen an den Einstellungen gelten für die noch ausstehenden Dateien.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
{#if pdfHandle}
|
||||||
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
<section
|
||||||
aria-labelledby="stamp-preview-title"
|
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||||
>
|
aria-labelledby="stamp-preview-title"
|
||||||
<div class="flex items-center justify-between gap-3">
|
>
|
||||||
<div>
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
|
<div>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
|
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">
|
||||||
</div>
|
Vorschau
|
||||||
<span class="text-xs font-semibold text-primary"
|
</h2>
|
||||||
>{currentPage} / {pdfHandle.pageCount}</span
|
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if pdfHandle.thumbnails[currentPage - 1]}
|
|
||||||
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
|
|
||||||
<div
|
|
||||||
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={currentThumbnail.imageUrl}
|
|
||||||
alt="Vorschau von Seite {currentPage}"
|
|
||||||
class="block w-full"
|
|
||||||
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
|
|
||||||
/>
|
|
||||||
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
|
|
||||||
{#if stampImage}
|
|
||||||
<div
|
|
||||||
data-testid="stamp-preview"
|
|
||||||
class={withBorder ? 'border-2' : ''}
|
|
||||||
style="border-color: {stampColor}; padding: {withBorder
|
|
||||||
? '2px'
|
|
||||||
: '0'}; transform: rotate({rotation}deg);"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={stampImage.objectUrl}
|
|
||||||
alt="Stempelbild"
|
|
||||||
class="block"
|
|
||||||
style="width: {imageSize * 0.5}px; max-width: 100%;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
data-testid="stamp-preview"
|
|
||||||
class="border-2 px-2 py-1 font-sans font-extrabold leading-tight whitespace-nowrap"
|
|
||||||
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
|
|
||||||
>
|
|
||||||
{previewText}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-xs font-semibold text-primary"
|
||||||
|
>{currentPage} / {pdfHandle.pageCount}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if pdfHandle.pageCount > 1}
|
{#if pdfHandle.thumbnails[currentPage - 1]}
|
||||||
<div class="flex items-center justify-center gap-3">
|
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
|
||||||
<Button
|
<div
|
||||||
variant="tertiary"
|
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||||
class="min-w-0 px-3"
|
|
||||||
onclick={showPreviousPage}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
aria-label="Vorherige Seite"
|
|
||||||
>
|
>
|
||||||
<ChevronLeft aria-hidden="true" />
|
<img
|
||||||
</Button>
|
src={currentThumbnail.imageUrl}
|
||||||
<Button
|
alt="Vorschau von Seite {currentPage}"
|
||||||
variant="tertiary"
|
class="block w-full"
|
||||||
class="min-w-0 px-3"
|
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
|
||||||
onclick={showNextPage}
|
/>
|
||||||
disabled={currentPage === pdfHandle.pageCount}
|
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
|
||||||
aria-label="Nächste Seite"
|
{#if stampImage}
|
||||||
>
|
<div
|
||||||
<ChevronRight aria-hidden="true" />
|
data-testid="stamp-preview"
|
||||||
</Button>
|
class={withBorder ? 'border-2' : ''}
|
||||||
</div>
|
style="border-color: {stampColor}; padding: {withBorder
|
||||||
{/if}
|
? '2px'
|
||||||
</section>
|
: '0'}; transform: rotate({rotation}deg);"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={stampImage.objectUrl}
|
||||||
|
alt="Stempelbild"
|
||||||
|
class="block"
|
||||||
|
style="width: {imageSize * 0.5}px; max-width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="stamp-preview"
|
||||||
|
class="border-2 px-2 py-1 font-sans leading-tight font-extrabold whitespace-nowrap"
|
||||||
|
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
|
||||||
|
>
|
||||||
|
{previewText}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pdfHandle.pageCount > 1}
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={showPreviousPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Vorherige Seite"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={showNextPage}
|
||||||
|
disabled={currentPage === pdfHandle.pageCount}
|
||||||
|
aria-label="Nächste Seite"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !applyToAll}
|
{#if pdfHandle && !applyToAll}
|
||||||
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
|
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
|
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
|
||||||
@@ -689,25 +628,21 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<BatchFileList
|
||||||
|
{items}
|
||||||
|
zipBaseName="stempel"
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col items-end gap-3">
|
<div class="flex flex-col items-end gap-3">
|
||||||
{#if error}
|
<ToolError message={error} class="w-full" />
|
||||||
<p
|
<Button size="large" onclick={startBatch} disabled={!canRun}>
|
||||||
class="w-full rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
|
{isRunning ? 'Stempel wird angewendet …' : 'Stempel anwenden'}
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<Button size="large" onclick={applyStamp} disabled={!canProcess}>
|
|
||||||
{isProcessing ? 'Stempel wird angewendet …' : 'Stempel anwenden & herunterladen'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else}
|
||||||
<p
|
<ToolError message={error} />
|
||||||
class="rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -4,14 +4,21 @@
|
|||||||
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import ToolError from '$lib/components/ToolError.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
import { getPositionCoords, parseHexColor, type WatermarkPosition } from '$lib/pdf-overlay';
|
import { getPositionCoords, parseHexColor, type WatermarkPosition } from '$lib/pdf-overlay';
|
||||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
type WatermarkKind = 'text' | 'image';
|
type WatermarkKind = 'text' | 'image';
|
||||||
|
|
||||||
|
const WATERMARK_FALLBACK_MESSAGE =
|
||||||
|
'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.';
|
||||||
|
const watermarkErrorMessage = (error: unknown) =>
|
||||||
|
pdfProcessingErrorMessage(error, WATERMARK_FALLBACK_MESSAGE);
|
||||||
|
|
||||||
let items = $state<BatchItem[]>([]);
|
let items = $state<BatchItem[]>([]);
|
||||||
let watermarkKind = $state<WatermarkKind>('text');
|
let watermarkKind = $state<WatermarkKind>('text');
|
||||||
let watermarkText = $state('VERTRAULICH');
|
let watermarkText = $state('VERTRAULICH');
|
||||||
@@ -67,7 +74,7 @@
|
|||||||
pdfHandle = handle;
|
pdfHandle = handle;
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
console.error('Failed to load PDF for watermarking:', loadError);
|
console.error('Failed to load PDF for watermarking:', loadError);
|
||||||
error = 'PDF konnte nicht geladen werden.';
|
error = pdfLoadErrorMessage(loadError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,7 +164,7 @@
|
|||||||
isRunning = true;
|
isRunning = true;
|
||||||
try {
|
try {
|
||||||
await runBatch(queue, processFile, updateItem, {
|
await runBatch(queue, processFile, updateItem, {
|
||||||
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.',
|
errorMessage: watermarkErrorMessage,
|
||||||
// Skip rows removed while they were still waiting in the queue.
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
shouldProcess: (id) => items.some((item) => item.id === id)
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
});
|
});
|
||||||
@@ -174,7 +181,7 @@
|
|||||||
isRunning = true;
|
isRunning = true;
|
||||||
try {
|
try {
|
||||||
await runBatch([item], processFile, updateItem, {
|
await runBatch([item], processFile, updateItem, {
|
||||||
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.'
|
errorMessage: watermarkErrorMessage
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isRunning = false;
|
isRunning = false;
|
||||||
@@ -338,11 +345,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
<ToolError message={error} />
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button onclick={startBatch} disabled={!canRun}>
|
<Button onclick={startBatch} disabled={!canRun}>
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import {
|
||||||
|
createEncryptedPdf,
|
||||||
|
createPdf,
|
||||||
|
dropFiles,
|
||||||
|
expectPdfDownload,
|
||||||
|
getPageContentStreamCount,
|
||||||
|
getPageText,
|
||||||
|
pdfDropzone,
|
||||||
|
trackPageErrors
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
const row = (page: import('@playwright/test').Page, fileName: string) =>
|
||||||
|
page.locator(`[data-exhibit-item="${fileName}"]`);
|
||||||
|
|
||||||
|
const labelOf = (page: import('@playwright/test').Page, fileName: string) =>
|
||||||
|
row(page, fileName).getAttribute('data-exhibit-label');
|
||||||
|
|
||||||
|
const renamedOf = (page: import('@playwright/test').Page, fileName: string) =>
|
||||||
|
row(page, fileName).locator('[data-exhibit-filename]').innerText();
|
||||||
|
|
||||||
|
test('exhibits numbers dropped PDFs, follows the list order and renames the downloads', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zweiBytes = await createPdf(2, 'Zwei');
|
||||||
|
const zweiInput = await PDFDocument.load(zweiBytes);
|
||||||
|
const originalStreamCounts = zweiInput
|
||||||
|
.getPages()
|
||||||
|
.map((_, pageIndex) => getPageContentStreamCount(zweiInput, pageIndex));
|
||||||
|
|
||||||
|
await page.goto('/tools/exhibits');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: zweiBytes },
|
||||||
|
{ name: 'drei.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Drei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.locator('[data-exhibit-item]')).toHaveCount(3);
|
||||||
|
expect(await labelOf(page, 'eins.pdf')).toBe('Anlage 1');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Anlage K', exact: true }).click();
|
||||||
|
await page.locator('#exhibit-start-number').fill('6');
|
||||||
|
await expect(row(page, 'eins.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 6');
|
||||||
|
await expect(row(page, 'drei.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 8');
|
||||||
|
expect(await renamedOf(page, 'zwei.pdf')).toBe('→ Anlage_K_07_zwei.pdf');
|
||||||
|
|
||||||
|
// Reordering the second file to the front renumbers the pending rows.
|
||||||
|
await page.getByRole('button', { name: 'zwei.pdf nach oben verschieben' }).click();
|
||||||
|
await expect(row(page, 'zwei.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 6');
|
||||||
|
await expect(row(page, 'eins.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 7');
|
||||||
|
expect(await renamedOf(page, 'zwei.pdf')).toBe('→ Anlage_K_06_zwei.pdf');
|
||||||
|
|
||||||
|
// The preview renders only the selected Anlage, page 1, on demand.
|
||||||
|
await page.getByRole('button', { name: 'Vorschau für zwei.pdf' }).click();
|
||||||
|
await expect(page.getByTestId('exhibit-preview')).toContainText('Anlage K 6');
|
||||||
|
await expect(page.getByTestId('exhibit-preview')).toHaveAttribute('data-position', 'top-right');
|
||||||
|
await expect(page.getByText('Seite 1 von 2')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(3, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'zwei.pdf herunterladen' }).click();
|
||||||
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^Anlage_K_06_zwei\.pdf$/);
|
||||||
|
|
||||||
|
const output = await PDFDocument.load(outputBytes);
|
||||||
|
expect(output.getPageCount()).toBe(2);
|
||||||
|
expect(getPageText(output, 0)).toContain('Anlage K 6');
|
||||||
|
// Only the first page is stamped by default; page 2 keeps its stream count.
|
||||||
|
expect(getPageContentStreamCount(output, 1)).toBe(originalStreamCounts[1]);
|
||||||
|
expect(getPageText(output, 1)).not.toContain('Anlage K 6');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exhibits switches to three digits from 98 and keeps labels unpadded', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/exhibits');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'a.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'A') },
|
||||||
|
{ name: 'b.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'B') },
|
||||||
|
{ name: 'c.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'C') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.locator('#exhibit-start-number').fill('98');
|
||||||
|
await expect(row(page, 'a.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 98');
|
||||||
|
expect(await renamedOf(page, 'a.pdf')).toBe('→ Anlage_098_a.pdf');
|
||||||
|
expect(await renamedOf(page, 'c.pdf')).toBe('→ Anlage_100_c.pdf');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(3, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'a.pdf herunterladen' }).click();
|
||||||
|
const first = await expectPdfDownload(await downloadPromise, /^Anlage_098_a\.pdf$/);
|
||||||
|
expect(getPageText(await PDFDocument.load(first), 0)).toContain('Anlage 98');
|
||||||
|
|
||||||
|
const secondPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'c.pdf herunterladen' }).click();
|
||||||
|
await expectPdfDownload(await secondPromise, /^Anlage_100_c\.pdf$/);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exhibits keeps the original names and honours the every-page option', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/exhibits');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'original.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Original') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.getByRole('checkbox', { name: 'Dateien umbenennen' }).uncheck();
|
||||||
|
await expect(row(page, 'original.pdf').locator('[data-exhibit-filename]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('checkbox', { name: 'Auf jeder Seite stempeln' }).check();
|
||||||
|
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'original.pdf herunterladen' }).click();
|
||||||
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^original\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(outputBytes);
|
||||||
|
expect(getPageText(output, 0)).toContain('Anlage 1');
|
||||||
|
expect(getPageText(output, 1)).toContain('Anlage 1');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exhibits freezes the number of finished rows and says so', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/exhibits');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'erst.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Erst') },
|
||||||
|
{ name: 'dann.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Dann') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
|
||||||
|
await expect(row(page, 'erst.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 1');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'dann.pdf nach oben verschieben' }).click();
|
||||||
|
|
||||||
|
await expect(row(page, 'dann.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 2');
|
||||||
|
await expect(row(page, 'erst.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 1');
|
||||||
|
expect(await renamedOf(page, 'dann.pdf')).toBe('→ Anlage_02_dann.pdf');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exhibits blocks an empty designation and recovers from a broken file', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/exhibits');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'gut.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Gut') },
|
||||||
|
{ name: 'kaputt.pdf', mimeType: 'application/pdf', buffer: Buffer.from('kein PDF') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.locator('#exhibit-prefix').fill(' ');
|
||||||
|
await expect(page.getByText('Bitte geben Sie eine Bezeichnung an')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Anlagen erstellen', exact: true })).toBeDisabled();
|
||||||
|
|
||||||
|
await page.locator('#exhibit-prefix').fill('Anlage B');
|
||||||
|
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
await expect(row(page, 'kaputt.pdf')).toContainText('Fehler');
|
||||||
|
await expect(row(page, 'gut.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage B 1');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exhibits bundles all finished Anlagen in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/exhibits');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Zwei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^anlagen_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
expect(Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!)))).sort()).toEqual([
|
||||||
|
'Anlage_01_eins.pdf',
|
||||||
|
'Anlage_02_zwei.pdf'
|
||||||
|
]);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exhibits reports encrypted PDFs as row errors and keeps going', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/exhibits');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'gesperrt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Gesperrt', 'geheim')
|
||||||
|
},
|
||||||
|
{ name: 'offen.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Offen') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
await expect(row(page, 'gesperrt.pdf')).toContainText('passwortgeschützt');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -123,6 +123,127 @@ export const getPageContentText = (document: PDFDocument, pageIndex: number) =>
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PDF_STRING_ESCAPES: Record<string, string> = {
|
||||||
|
n: '\n',
|
||||||
|
r: '\r',
|
||||||
|
t: '\t',
|
||||||
|
b: '\b',
|
||||||
|
f: '\f',
|
||||||
|
'(': '(',
|
||||||
|
')': ')',
|
||||||
|
'\\': '\\'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Decodes a single PDF string token (`<hex>` or `(literal)`). */
|
||||||
|
const decodePdfString = (token: string): string => {
|
||||||
|
if (token.startsWith('<')) {
|
||||||
|
return Buffer.from(token.slice(1, -1).replace(/\s/g, ''), 'hex').toString('latin1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = token.slice(1, -1);
|
||||||
|
let decoded = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < raw.length; index += 1) {
|
||||||
|
const char = raw[index];
|
||||||
|
|
||||||
|
if (char !== '\\') {
|
||||||
|
decoded += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped = raw[index + 1];
|
||||||
|
index += 1;
|
||||||
|
|
||||||
|
if (escaped === undefined) break;
|
||||||
|
|
||||||
|
if (escaped >= '0' && escaped <= '7') {
|
||||||
|
let octal = escaped;
|
||||||
|
|
||||||
|
while (octal.length < 3 && raw[index + 1] >= '0' && raw[index + 1] <= '7') {
|
||||||
|
octal += raw[(index += 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded += String.fromCharCode(Number.parseInt(octal, 8));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded += PDF_STRING_ESCAPES[escaped] ?? escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PDF_STRING_TOKEN = String.raw`<[0-9a-fA-F\s]*>|\((?:\\.|[^()\\])*\)`;
|
||||||
|
const TEXT_SHOWING_PATTERN = new RegExp(
|
||||||
|
String.raw`\[((?:[^\[\]\\]|\\.)*)\]\s*TJ|(${PDF_STRING_TOKEN})\s*(?:Tj|'|")`,
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
const STRING_TOKEN_PATTERN = new RegExp(PDF_STRING_TOKEN, 'g');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded operands of every text-showing operator (`Tj`, `TJ`, `'`, `"`).
|
||||||
|
* pdf-lib writes drawn text as hex strings (`<426C2E2031> Tj`), so the raw
|
||||||
|
* content stream is not readable without this decoding.
|
||||||
|
*
|
||||||
|
* Limitations: other text operators (`Td`, `Tf`, …) and non-text strings are
|
||||||
|
* ignored. A page that shows text through an operator this helper does not know
|
||||||
|
* returns `[]`, so `not.toContain(...)` assertions on it prove nothing — assert
|
||||||
|
* on a positive match instead.
|
||||||
|
*/
|
||||||
|
export const getPageTextValues = (document: PDFDocument, pageIndex: number) => {
|
||||||
|
const content = getPageContentText(document, pageIndex);
|
||||||
|
const values: string[] = [];
|
||||||
|
|
||||||
|
for (const match of content.matchAll(TEXT_SHOWING_PATTERN)) {
|
||||||
|
const [, arrayBody, singleToken] = match;
|
||||||
|
|
||||||
|
if (singleToken !== undefined) {
|
||||||
|
values.push(decodePdfString(singleToken));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of arrayBody.matchAll(STRING_TOKEN_PATTERN)) {
|
||||||
|
values.push(decodePdfString(token[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPageText = (document: PDFDocument, pageIndex: number) =>
|
||||||
|
getPageTextValues(document, pageIndex).join('\n');
|
||||||
|
|
||||||
|
export type PageTextMatrix = { a: number; b: number; c: number; d: number; x: number; y: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translation and rotation of every text matrix (`Tm`) in a page's content
|
||||||
|
* stream, in drawing order. Lets tests assert on the drawing geometry without
|
||||||
|
* hard-coding the hex-encoded text operands.
|
||||||
|
*/
|
||||||
|
export const getPageTextMatrices = (document: PDFDocument, pageIndex: number): PageTextMatrix[] => {
|
||||||
|
const content = getPageContentText(document, pageIndex);
|
||||||
|
const matrices: PageTextMatrix[] = [];
|
||||||
|
|
||||||
|
for (const match of content.matchAll(
|
||||||
|
/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) Tm/g
|
||||||
|
)) {
|
||||||
|
const [a, b, c, d, x, y] = match.slice(1).map(Number);
|
||||||
|
matrices.push({ a, b, c, d, x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matrices.length === 0) throw new Error(`Page ${pageIndex + 1} contains no text matrix.`);
|
||||||
|
|
||||||
|
return matrices;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** First text matrix of a page (the only one in a single-mark document). */
|
||||||
|
export const getPageTextMatrix = (document: PDFDocument, pageIndex: number): PageTextMatrix =>
|
||||||
|
getPageTextMatrices(document, pageIndex)[0];
|
||||||
|
|
||||||
|
/** Angle of a text matrix in degrees, normalised to `0 … 359`. */
|
||||||
|
export const getPageTextAngle = (matrix: PageTextMatrix) =>
|
||||||
|
((Math.atan2(matrix.b, matrix.a) * 180) / Math.PI + 360) % 360;
|
||||||
|
|
||||||
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
|
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
|
||||||
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
|
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
|
||||||
document.encrypt({
|
document.encrypt({
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { PAGE_NUMBER_MARGIN } from '../../src/lib/pdf-pagination';
|
||||||
|
import {
|
||||||
|
createEncryptedPdf,
|
||||||
|
createPdf,
|
||||||
|
createPreRotatedPdf,
|
||||||
|
dropFiles,
|
||||||
|
expectPdfDownload,
|
||||||
|
getPageContentText,
|
||||||
|
getPageText,
|
||||||
|
getPageTextAngle,
|
||||||
|
getPageTextMatrices,
|
||||||
|
getPageTextValues,
|
||||||
|
pdfDropzone,
|
||||||
|
trackPageErrors
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of text operands equal to `label` on a page. The source documents carry
|
||||||
|
* their own text, so this counts the stamped sheet label instead of comparing
|
||||||
|
* the whole page.
|
||||||
|
*/
|
||||||
|
const countLabel = (document: PDFDocument, pageIndex: number, label: string) =>
|
||||||
|
getPageTextValues(document, pageIndex).filter((value) => value === label).length;
|
||||||
|
|
||||||
|
const downloadPaginated = async (page: Page, fileName: RegExp) => {
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Paginieren & herunterladen' }).click();
|
||||||
|
return expectPdfDownload(await downloadPromise, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('pagination stamps every page with an Aktenzeichen and the configured start number', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Seite 1 von 3')).toBeVisible();
|
||||||
|
await page.locator('#pagination-start').fill('26');
|
||||||
|
await page.locator('#pagination-text').fill('123 Ja 60/26');
|
||||||
|
await expect(page.getByTestId('pagination-example')).toContainText(
|
||||||
|
'Beispiel: 123 Ja 60/26, Bl. 26'
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Nächste Seite' }).click();
|
||||||
|
await expect(page.getByText('Seite 2 von 3')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('pagination-preview')).toContainText('123 Ja 60/26, Bl. 27');
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^akte_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
// Exactly one label per page: a duplicated draw would still pass `toContain`.
|
||||||
|
expect(countLabel(output, 0, '123 Ja 60/26, Bl. 26')).toBe(1);
|
||||||
|
expect(countLabel(output, 1, '123 Ja 60/26, Bl. 27')).toBe(1);
|
||||||
|
expect(countLabel(output, 2, '123 Ja 60/26, Bl. 28')).toBe(1);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination can keep the additional text on the first page only', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.locator('#pagination-start').fill('7');
|
||||||
|
await page.locator('#pagination-text').fill('AZ 7/26');
|
||||||
|
await page.locator('#pagination-format').selectOption('s');
|
||||||
|
await page.getByLabel('Nur auf der ersten Seite').check();
|
||||||
|
await expect(page.getByTestId('pagination-example')).toContainText('AZ 7/26, S. 7');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Nächste Seite' }).click();
|
||||||
|
await expect(page.getByTestId('pagination-preview')).toHaveText('S. 8');
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^akte_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
expect(getPageText(output, 0)).toContain('AZ 7/26, S. 7');
|
||||||
|
expect(getPageText(output, 1)).toContain('S. 8');
|
||||||
|
expect(getPageText(output, 1)).not.toContain('AZ 7/26');
|
||||||
|
expect(getPageText(output, 2)).toContain('S. 9');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination supports number-only labels and jumps to a page', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'nummern.pdf', mimeType: 'application/pdf', buffer: await createPdf(11, 'Nummern') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.locator('#pagination-start').fill('42');
|
||||||
|
await page.locator('#pagination-format').selectOption('nummer');
|
||||||
|
await expect(page.getByTestId('pagination-example')).toHaveText('Beispiel: 42');
|
||||||
|
|
||||||
|
await page.getByLabel('Springe zu Seite').fill('11');
|
||||||
|
await expect(page.getByText('Seite 11 von 11')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('pagination-preview')).toHaveText('52');
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^nummern_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(11);
|
||||||
|
expect(getPageText(output, 0)).toContain('42');
|
||||||
|
expect(getPageText(output, 10)).toContain('52');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination keeps the page number upright on rotated scans', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'gedreht.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createPreRotatedPdf(2, 'Gedreht')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^gedreht_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(2);
|
||||||
|
|
||||||
|
// `createPreRotatedPdf` stores 420×594 pages and puts /Rotate 90 on page 1, so
|
||||||
|
// the page is displayed as 594×420. The label is counter-rotated so it reads
|
||||||
|
// horizontally on screen instead of sideways.
|
||||||
|
// The stamp is the last text drawn on the page.
|
||||||
|
const rotated = getPageTextMatrices(output, 0).at(-1)!;
|
||||||
|
expect(Math.round(getPageTextAngle(rotated))).toBe(90);
|
||||||
|
expect(countLabel(output, 0, 'Bl. 1')).toBe(1);
|
||||||
|
// Visual bottom centre maps back into user space as x = stored width − margin.
|
||||||
|
expect(rotated.x).toBeCloseTo(420 - PAGE_NUMBER_MARGIN, -1);
|
||||||
|
// …and stays horizontally centred on the displayed width.
|
||||||
|
expect(Math.abs(rotated.y - 594 / 2)).toBeLessThan(15);
|
||||||
|
|
||||||
|
const plain = getPageTextMatrices(output, 1).at(-1)!;
|
||||||
|
expect(Math.round(getPageTextAngle(plain))).toBe(0);
|
||||||
|
expect(countLabel(output, 1, 'Bl. 2')).toBe(1);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination offers the "Seite" format and applies it to every page', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'formate.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Formate') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.locator('#pagination-format').selectOption('seite');
|
||||||
|
await expect(page.getByTestId('pagination-example')).toHaveText('Beispiel: Seite 1');
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^formate_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(countLabel(output, 0, 'Seite 1')).toBe(1);
|
||||||
|
expect(countLabel(output, 1, 'Seite 2')).toBe(1);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination explains how to handle a password-protected PDF', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('alert')).toContainText('passwortgeschützt');
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Passwort entfernen');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge hands the merged PDF over to pagination', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/merge');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Zusammenfügen & paginieren' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/tools\/pagination$/);
|
||||||
|
await expect(page.getByText('Seite 1 von 3')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^zusammengefuegt_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
expect(getPageText(output, 0)).toContain('Bl. 1');
|
||||||
|
expect(getPageText(output, 1)).toContain('Bl. 2');
|
||||||
|
expect(getPageText(output, 2)).toContain('Bl. 3');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
+61
-4
@@ -1,4 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import {
|
import {
|
||||||
createEncryptedPdf,
|
createEncryptedPdf,
|
||||||
@@ -29,8 +31,10 @@ test('stamp presets fill the editable text and EILT is applied to every page', a
|
|||||||
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
|
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
|
||||||
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
|
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'akte.pdf herunterladen' }).click();
|
||||||
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
|
||||||
const output = await PDFDocument.load(outputBytes);
|
const output = await PDFDocument.load(outputBytes);
|
||||||
expect(output.getPageCount()).toBe(3);
|
expect(output.getPageCount()).toBe(3);
|
||||||
@@ -58,8 +62,10 @@ test('stamp custom text adds a German date and only changes selected pages', asy
|
|||||||
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
|
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
|
||||||
await page.getByRole('button', { name: /Seite 2/ }).click();
|
await page.getByRole('button', { name: /Seite 2/ }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'auswahl.pdf herunterladen' }).click();
|
||||||
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
|
||||||
const output = await PDFDocument.load(outputBytes);
|
const output = await PDFDocument.load(outputBytes);
|
||||||
expect(output.getPageCount()).toBe(3);
|
expect(output.getPageCount()).toBe(3);
|
||||||
@@ -89,8 +95,10 @@ test('stamp uses an image instead of text and makes the border optional', async
|
|||||||
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
|
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
|
||||||
).not.toBeChecked();
|
).not.toBeChecked();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'bildakte.pdf herunterladen' }).click();
|
||||||
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
|
||||||
const output = await PDFDocument.load(outputBytes);
|
const output = await PDFDocument.load(outputBytes);
|
||||||
expect(output.getPageCount()).toBe(2);
|
expect(output.getPageCount()).toBe(2);
|
||||||
@@ -103,8 +111,18 @@ test('stamp uses an image instead of text and makes the border optional', async
|
|||||||
|
|
||||||
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
|
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
|
||||||
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
|
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
|
||||||
|
|
||||||
|
// Re-add the file so the changed option applies to a fresh pending row.
|
||||||
|
await page.getByRole('button', { name: 'bildakte.pdf entfernen' }).click();
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const borderedDownloadPromise = page.waitForEvent('download');
|
const borderedDownloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'bildakte.pdf herunterladen' }).click();
|
||||||
const borderedBytes = await expectPdfDownload(
|
const borderedBytes = await expectPdfDownload(
|
||||||
await borderedDownloadPromise,
|
await borderedDownloadPromise,
|
||||||
/^bildakte_stempel\.pdf$/
|
/^bildakte_stempel\.pdf$/
|
||||||
@@ -120,6 +138,45 @@ test('stamp uses an image instead of text and makes the border optional', async
|
|||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stamp batch applies to every file and bundles results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') },
|
||||||
|
{
|
||||||
|
name: 'kaputt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: Buffer.from('kein gueltiges PDF')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (4)')).toBeVisible();
|
||||||
|
// With several files the stamp is applied to every page and no preview is loaded.
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Vorschau' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
|
||||||
|
await expect(page.getByText('Fehler')).toHaveCount(2);
|
||||||
|
await expect(page.getByText('Dieses PDF ist passwortgeschützt')).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^stempel_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual(['eins_stempel.pdf', 'zwei_stempel.pdf']);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
|
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
|
||||||
const pageErrors = trackPageErrors(page);
|
const pageErrors = trackPageErrors(page);
|
||||||
await page.goto('/tools/stamp');
|
await page.goto('/tools/stamp');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ test('start page exposes only working tools and forwards a dropped ZIP to beA',
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible();
|
await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /Paginieren/ })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
|
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
|
||||||
|
|
||||||
await dropFiles(page, 'window', [
|
await dropFiles(page, 'window', [
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { tools } from '../../src/lib/tools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tool registry, its icon map, the route files and the page headings all
|
||||||
|
* live in different files. This catches the case where one of them drifts and a
|
||||||
|
* registered tool 404s or renders under a different title.
|
||||||
|
*/
|
||||||
|
for (const tool of tools) {
|
||||||
|
test(`the registry entry "${tool.title}" renders at /tools/${tool.slug}`, async ({ page }) => {
|
||||||
|
const response = await page.goto(`/tools/${tool.slug}`);
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: tool.title })).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
DEFAULT_EXHIBIT_PREFIX,
|
||||||
|
MAX_EXHIBIT_PREFIX_LENGTH,
|
||||||
|
MAX_EXHIBIT_START_NUMBER,
|
||||||
|
MIN_EXHIBIT_START_NUMBER,
|
||||||
|
buildExhibitFileName,
|
||||||
|
buildExhibitLabel,
|
||||||
|
buildExhibitPrefixSlug,
|
||||||
|
getExhibitNumberWidth,
|
||||||
|
normalizeExhibitPrefix,
|
||||||
|
normalizeExhibitStartNumber
|
||||||
|
} from '../../src/lib/exhibit-naming';
|
||||||
|
|
||||||
|
test('the prefix is trimmed and capped', () => {
|
||||||
|
expect(normalizeExhibitPrefix(' Anlage K ')).toBe('Anlage K');
|
||||||
|
expect(normalizeExhibitPrefix('')).toBe('');
|
||||||
|
expect(normalizeExhibitPrefix('A'.repeat(MAX_EXHIBIT_PREFIX_LENGTH + 10))).toHaveLength(
|
||||||
|
MAX_EXHIBIT_PREFIX_LENGTH
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labels use one space and never pad the number', () => {
|
||||||
|
expect(buildExhibitLabel('Anlage K', 6)).toBe('Anlage K 6');
|
||||||
|
expect(buildExhibitLabel(' Anlage ', 1)).toBe('Anlage 1');
|
||||||
|
expect(buildExhibitLabel('Anlage', 98)).toBe('Anlage 98');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the padding width follows the highest assigned number', () => {
|
||||||
|
expect(getExhibitNumberWidth(9)).toBe(2);
|
||||||
|
expect(getExhibitNumberWidth(99)).toBe(2);
|
||||||
|
expect(getExhibitNumberWidth(100)).toBe(3);
|
||||||
|
expect(getExhibitNumberWidth(1000)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file names prefix the number and keep the original base name', () => {
|
||||||
|
expect(
|
||||||
|
buildExhibitFileName({
|
||||||
|
prefix: 'Anlage K',
|
||||||
|
number: 6,
|
||||||
|
highestNumber: 8,
|
||||||
|
originalName: 'Klageschrift.pdf'
|
||||||
|
})
|
||||||
|
).toBe('Anlage_K_06_Klageschrift.pdf');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildExhibitFileName({
|
||||||
|
prefix: 'Anlage K',
|
||||||
|
number: 98,
|
||||||
|
highestNumber: 100,
|
||||||
|
originalName: 'Anlage.PDF'
|
||||||
|
})
|
||||||
|
).toBe('Anlage_K_098_Anlage.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsafe characters never reach the file name', () => {
|
||||||
|
expect(
|
||||||
|
buildExhibitFileName({
|
||||||
|
prefix: 'Anlage K/1',
|
||||||
|
number: 1,
|
||||||
|
highestNumber: 1,
|
||||||
|
originalName: 'a:b*c?.pdf'
|
||||||
|
})
|
||||||
|
).toBe('Anlage_K1_01_abc.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty prefix falls back to the default slug', () => {
|
||||||
|
expect(buildExhibitPrefixSlug(' ')).toBe(DEFAULT_EXHIBIT_PREFIX);
|
||||||
|
expect(
|
||||||
|
buildExhibitFileName({ prefix: '', number: 3, highestNumber: 3, originalName: 'x.pdf' })
|
||||||
|
).toBe('Anlage_03_x.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the start number is clamped and survives an empty field', () => {
|
||||||
|
expect(normalizeExhibitStartNumber(1)).toBe(1);
|
||||||
|
expect(normalizeExhibitStartNumber(0)).toBe(MIN_EXHIBIT_START_NUMBER);
|
||||||
|
expect(normalizeExhibitStartNumber(-5)).toBe(MIN_EXHIBIT_START_NUMBER);
|
||||||
|
expect(normalizeExhibitStartNumber(6.6)).toBe(7);
|
||||||
|
expect(normalizeExhibitStartNumber(undefined)).toBe(1);
|
||||||
|
expect(normalizeExhibitStartNumber(null)).toBe(1);
|
||||||
|
expect(normalizeExhibitStartNumber('')).toBe(1);
|
||||||
|
expect(normalizeExhibitStartNumber(1_000_000)).toBe(MAX_EXHIBIT_START_NUMBER);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
ENCRYPTED_PDF_MESSAGE,
|
||||||
|
TextOverlayError,
|
||||||
|
classifyPdfError,
|
||||||
|
pdfLoadErrorMessage,
|
||||||
|
pdfProcessingErrorMessage
|
||||||
|
} from '../../src/lib/pdf-errors';
|
||||||
|
|
||||||
|
const encryptedError = () =>
|
||||||
|
new Error(
|
||||||
|
'Input document to `PDFDocument.load` is encrypted. You can use `PDFDocument.load(..., { ignoreEncryption: true })`'
|
||||||
|
);
|
||||||
|
|
||||||
|
test('overlay failures keep their own copy', () => {
|
||||||
|
const error = new TextOverlayError('Der Text ist zu lang.');
|
||||||
|
|
||||||
|
expect(classifyPdfError(error)).toBe('text-overlay');
|
||||||
|
expect(pdfProcessingErrorMessage(error, 'Fallback')).toBe('Der Text ist zu lang.');
|
||||||
|
expect(pdfLoadErrorMessage(error)).toBe('Der Text ist zu lang.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encrypted PDFs map to the shared password hint', () => {
|
||||||
|
expect(classifyPdfError(encryptedError())).toBe('encrypted');
|
||||||
|
expect(pdfProcessingErrorMessage(encryptedError(), 'Fallback')).toBe(ENCRYPTED_PDF_MESSAGE);
|
||||||
|
expect(pdfLoadErrorMessage(encryptedError())).toBe(ENCRYPTED_PDF_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown failures fall back to the caller copy', () => {
|
||||||
|
const error = new Error('Invalid PDF structure');
|
||||||
|
|
||||||
|
expect(classifyPdfError(error)).toBe('unknown');
|
||||||
|
expect(pdfProcessingErrorMessage(error, 'Fehler beim Drehen der Seiten.')).toBe(
|
||||||
|
'Fehler beim Drehen der Seiten.'
|
||||||
|
);
|
||||||
|
expect(pdfLoadErrorMessage(error, 'Eigene Meldung.')).toBe('Eigene Meldung.');
|
||||||
|
expect(pdfLoadErrorMessage(error)).toContain('PDF konnte nicht geladen werden');
|
||||||
|
// Tool-specific copy must never be overridden by the classification.
|
||||||
|
expect(pdfProcessingErrorMessage('boom', 'Eigene Meldung.')).toBe('Eigene Meldung.');
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { TextOverlayError } from '../../src/lib/pdf-errors';
|
||||||
|
import type { OverlayPosition } from '../../src/lib/pdf-overlay';
|
||||||
|
import {
|
||||||
|
MAX_FREE_TEXT_LENGTH,
|
||||||
|
PAGINATION_FORMATS,
|
||||||
|
buildPaginationLabel,
|
||||||
|
normalizeFreeText,
|
||||||
|
normalizeStartNumber,
|
||||||
|
paginatePdf,
|
||||||
|
type PaginationFormat,
|
||||||
|
type PaginationOptions,
|
||||||
|
type PaginationTextScope
|
||||||
|
} from '../../src/lib/pdf-pagination';
|
||||||
|
import { getPageTextValues } from '../e2e/helpers';
|
||||||
|
|
||||||
|
const labelOptions = (
|
||||||
|
overrides: Partial<{
|
||||||
|
startNumber: number;
|
||||||
|
freeText: string;
|
||||||
|
textScope: PaginationTextScope;
|
||||||
|
format: PaginationFormat;
|
||||||
|
}> = {}
|
||||||
|
) => ({
|
||||||
|
startNumber: 1,
|
||||||
|
freeText: '',
|
||||||
|
textScope: 'all' as const,
|
||||||
|
format: 'blatt' as const,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginationOptions = (overrides: Partial<PaginationOptions> = {}): PaginationOptions => ({
|
||||||
|
startNumber: 1,
|
||||||
|
freeText: '',
|
||||||
|
textScope: 'all',
|
||||||
|
format: 'blatt',
|
||||||
|
position: 'bottom-center' as OverlayPosition,
|
||||||
|
fontSize: 11,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Blank pages, so the stamped sheet number is the only text in the output. */
|
||||||
|
const createPdf = async (pageCount: number, size: [number, number] = [420, 594]) => {
|
||||||
|
const document = await PDFDocument.create();
|
||||||
|
|
||||||
|
for (let index = 0; index < pageCount; index += 1) document.addPage(size);
|
||||||
|
|
||||||
|
return new Uint8Array(await document.save());
|
||||||
|
};
|
||||||
|
|
||||||
|
test('every format is offered with a rendered example label', () => {
|
||||||
|
expect(PAGINATION_FORMATS.map((format) => format.value)).toEqual([
|
||||||
|
'blatt',
|
||||||
|
'seite',
|
||||||
|
's',
|
||||||
|
'nummer'
|
||||||
|
]);
|
||||||
|
expect(PAGINATION_FORMATS.map((format) => format.label)).toEqual([
|
||||||
|
'Bl. 1',
|
||||||
|
'Seite 1',
|
||||||
|
'S. 1',
|
||||||
|
'1'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label combines the free text with the ascending sheet number', () => {
|
||||||
|
expect(buildPaginationLabel(0, labelOptions({ startNumber: 26 }))).toBe('Bl. 26');
|
||||||
|
expect(buildPaginationLabel(2, labelOptions({ startNumber: 26 }))).toBe('Bl. 28');
|
||||||
|
expect(buildPaginationLabel(0, labelOptions({ freeText: ' 123 Ja 60/26 ' }))).toBe(
|
||||||
|
'123 Ja 60/26, Bl. 1'
|
||||||
|
);
|
||||||
|
expect(buildPaginationLabel(0, labelOptions({ format: 'seite', startNumber: 6 }))).toBe(
|
||||||
|
'Seite 6'
|
||||||
|
);
|
||||||
|
expect(buildPaginationLabel(0, labelOptions({ format: 's', startNumber: 6 }))).toBe('S. 6');
|
||||||
|
expect(buildPaginationLabel(0, labelOptions({ format: 'nummer', startNumber: 6 }))).toBe('6');
|
||||||
|
// No leading zeros — a start number of 6 is not "06".
|
||||||
|
expect(buildPaginationLabel(0, labelOptions({ startNumber: 6 }))).toBe('Bl. 6');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the free text is dropped on later pages for the first-page scope', () => {
|
||||||
|
const options = labelOptions({ freeText: 'AZ 7/26', textScope: 'first', format: 's' });
|
||||||
|
|
||||||
|
expect(buildPaginationLabel(0, options)).toBe('AZ 7/26, S. 1');
|
||||||
|
expect(buildPaginationLabel(1, options)).toBe('S. 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start number and free text are normalised at the boundary', () => {
|
||||||
|
expect(normalizeStartNumber(0)).toBe(1);
|
||||||
|
expect(normalizeStartNumber(-5)).toBe(1);
|
||||||
|
expect(normalizeStartNumber(2.6)).toBe(3);
|
||||||
|
expect(normalizeStartNumber(Number.NaN)).toBe(1);
|
||||||
|
expect(normalizeStartNumber(1_000_000)).toBe(99_999);
|
||||||
|
|
||||||
|
expect(normalizeFreeText(' 123 Ja 60/26 ')).toBe('123 Ja 60/26');
|
||||||
|
expect(normalizeFreeText('x'.repeat(90))).toHaveLength(MAX_FREE_TEXT_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paginatePdf stamps each page exactly once', async () => {
|
||||||
|
const output = await paginatePdf(
|
||||||
|
await createPdf(3),
|
||||||
|
paginationOptions({ startNumber: 26, freeText: '123 Ja 60/26' })
|
||||||
|
);
|
||||||
|
const document = await PDFDocument.load(output);
|
||||||
|
|
||||||
|
expect(document.getPageCount()).toBe(3);
|
||||||
|
expect(getPageTextValues(document, 0)).toEqual(['123 Ja 60/26, Bl. 26']);
|
||||||
|
expect(getPageTextValues(document, 1)).toEqual(['123 Ja 60/26, Bl. 27']);
|
||||||
|
expect(getPageTextValues(document, 2)).toEqual(['123 Ja 60/26, Bl. 28']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paginatePdf honours the first-page-only scope', async () => {
|
||||||
|
const output = await paginatePdf(
|
||||||
|
await createPdf(2),
|
||||||
|
paginationOptions({ freeText: 'AZ 7/26', textScope: 'first', format: 'nummer' })
|
||||||
|
);
|
||||||
|
const document = await PDFDocument.load(output);
|
||||||
|
|
||||||
|
expect(getPageTextValues(document, 0)).toEqual(['AZ 7/26, 1']);
|
||||||
|
expect(getPageTextValues(document, 1)).toEqual(['2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a label that cannot fit at the minimum font size fails loudly', async () => {
|
||||||
|
await expect(
|
||||||
|
paginatePdf(
|
||||||
|
await createPdf(1, [120, 160]),
|
||||||
|
paginationOptions({ freeText: 'x'.repeat(MAX_FREE_TEXT_LENGTH), fontSize: 18 })
|
||||||
|
)
|
||||||
|
).rejects.toBeInstanceOf(TextOverlayError);
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { degrees, PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||||
|
import { TextOverlayError } from '../../src/lib/pdf-errors';
|
||||||
|
import {
|
||||||
|
TEXT_OVERLAY_MARGIN,
|
||||||
|
drawTextOverlay,
|
||||||
|
getRotatedBoxPlacement
|
||||||
|
} from '../../src/lib/pdf-text-overlay';
|
||||||
|
import { getPageText, getPageTextAngle, getPageTextMatrix } from '../e2e/helpers';
|
||||||
|
|
||||||
|
const PAGE_WIDTH = 420;
|
||||||
|
const PAGE_HEIGHT = 594;
|
||||||
|
const MARGIN = 28;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a point from default user space to the displayed page. Viewers rotate the
|
||||||
|
* content clockwise by `/Rotate`, so this is the forward transformation.
|
||||||
|
*/
|
||||||
|
const toVisualPoint = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
rotation: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
) => {
|
||||||
|
switch (rotation) {
|
||||||
|
case 90:
|
||||||
|
return { x: point.y, y: width - point.x };
|
||||||
|
case 180:
|
||||||
|
return { x: width - point.x, y: height - point.y };
|
||||||
|
case 270:
|
||||||
|
return { x: height - point.y, y: point.x };
|
||||||
|
default:
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPageWithMark = async (rotation: number) => {
|
||||||
|
const document = await PDFDocument.create();
|
||||||
|
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
const page = document.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
||||||
|
page.setRotation(degrees(rotation));
|
||||||
|
drawTextOverlay(page, {
|
||||||
|
font,
|
||||||
|
text: 'Bl. 1',
|
||||||
|
size: 11,
|
||||||
|
color: rgb(0.1, 0.1, 0.1),
|
||||||
|
position: 'bottom-center',
|
||||||
|
margin: MARGIN,
|
||||||
|
compensatePageRotation: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return PDFDocument.load(await document.save());
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rotation of [0, 90, 180, 270]) {
|
||||||
|
test(`bottom-center mark stays at the visual bottom on a /Rotate ${rotation} page`, async () => {
|
||||||
|
const output = await createPageWithMark(rotation);
|
||||||
|
const matrix = getPageTextMatrix(output, 0);
|
||||||
|
const visualWidth = rotation % 180 === 0 ? PAGE_WIDTH : PAGE_HEIGHT;
|
||||||
|
const origin = toVisualPoint({ x: matrix.x, y: matrix.y }, rotation, PAGE_WIDTH, PAGE_HEIGHT);
|
||||||
|
|
||||||
|
// Horizontally centred on the displayed page.
|
||||||
|
expect(Math.abs(origin.x - visualWidth / 2)).toBeLessThan(30);
|
||||||
|
// Close to the bottom margin (the baseline sits a few points above it).
|
||||||
|
expect(origin.y).toBeGreaterThan(MARGIN - 2);
|
||||||
|
expect(origin.y).toBeLessThan(MARGIN + 20);
|
||||||
|
// The glyphs read horizontally: the drawn angle cancels the page rotation.
|
||||||
|
expect(Math.round(getPageTextAngle(matrix))).toBe(rotation);
|
||||||
|
expect(getPageText(output, 0)).toBe('Bl. 1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a mark that does not fit raises a TextOverlayError instead of shrinking forever', async () => {
|
||||||
|
const document = await PDFDocument.create();
|
||||||
|
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
const page = document.addPage([120, 160]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
drawTextOverlay(page, {
|
||||||
|
font,
|
||||||
|
text: 'Ein sehr langer Stempeltext, der nicht auf die Seite passt',
|
||||||
|
size: 18,
|
||||||
|
color: rgb(0, 0, 0),
|
||||||
|
position: 'bottom-center',
|
||||||
|
margin: MARGIN,
|
||||||
|
framed: true
|
||||||
|
})
|
||||||
|
).toThrow(TextOverlayError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('characters the standard font cannot encode are reported as such', async () => {
|
||||||
|
const document = await PDFDocument.create();
|
||||||
|
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
const page = document.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
drawTextOverlay(page, {
|
||||||
|
font,
|
||||||
|
text: 'Beschluss \u{1F600}',
|
||||||
|
size: 11,
|
||||||
|
color: rgb(0, 0, 0),
|
||||||
|
position: 'top-right',
|
||||||
|
margin: MARGIN
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(TextOverlayError);
|
||||||
|
expect((thrown as Error).message).toContain('nicht dargestellt werden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rotated mark keeps its bounding box inside the page', async () => {
|
||||||
|
const boxWidth = 200;
|
||||||
|
const boxHeight = 40;
|
||||||
|
|
||||||
|
for (const angle of [0, 45, 90, 135]) {
|
||||||
|
const placement = getRotatedBoxPlacement(
|
||||||
|
PAGE_WIDTH,
|
||||||
|
PAGE_HEIGHT,
|
||||||
|
boxWidth,
|
||||||
|
boxHeight,
|
||||||
|
'bottom-center',
|
||||||
|
angle,
|
||||||
|
TEXT_OVERLAY_MARGIN
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(placement.x).toBeGreaterThanOrEqual(TEXT_OVERLAY_MARGIN - 0.001);
|
||||||
|
expect(placement.y).toBeGreaterThanOrEqual(TEXT_OVERLAY_MARGIN - 0.001);
|
||||||
|
expect(placement).not.toHaveProperty('cosine');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
clearPendingFiles,
|
||||||
|
queuePendingFiles,
|
||||||
|
takePendingFiles
|
||||||
|
} from '../../src/lib/pending-files';
|
||||||
|
|
||||||
|
const pdf = () => new File(['%PDF-1.7'], 'akte.pdf', { type: 'application/pdf' });
|
||||||
|
const zip = () => new File(['PK'], 'archiv.zip', { type: 'application/zip' });
|
||||||
|
|
||||||
|
test.beforeEach(() => clearPendingFiles());
|
||||||
|
|
||||||
|
test('a target only receives the files queued for it', () => {
|
||||||
|
queuePendingFiles([zip()], 'bea');
|
||||||
|
queuePendingFiles([pdf()], 'pagination');
|
||||||
|
|
||||||
|
const paginationFiles = takePendingFiles('pagination');
|
||||||
|
|
||||||
|
expect(paginationFiles.map((file) => file.name)).toEqual(['akte.pdf']);
|
||||||
|
// The beA handoff must survive a pagination visit instead of being dropped.
|
||||||
|
expect(takePendingFiles('bea').map((file) => file.name)).toEqual(['archiv.zip']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('taking a target clears only that target', () => {
|
||||||
|
queuePendingFiles([pdf(), pdf()], 'pagination');
|
||||||
|
|
||||||
|
expect(takePendingFiles('pagination')).toHaveLength(2);
|
||||||
|
expect(takePendingFiles('pagination')).toHaveLength(0);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user